feat(pipelines): add pipelines service methods
+ 481
- 3
enjoy pipelinesService.* !

app/components/Chip.ts
@@ -20,7 +20,7 @@ export const Chip = styled.div<WithThemeSchemeProp & { color?: string }>`
     text-decoration: none !important;
 
     color: ${color
-      ? Color(color).alpha(1).lightness(0.3).toString()
+      ? Color(color).alpha(1).toString()
       : NamedColors.TEXT_DEFAULT[themeScheme]};
     background-color: ${color
       ? Color(color).alpha(0.2).toString()

new file
app/services/pipelines/cancelRunner.ts
@@ -0,0 +1,15 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeCancelRunner: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string],
+  Promise<any>
+> = ({ runner }) => {
+  return async (pipelineId: string) => {
+    return runner.cancelRun(pipelineId);
+  };
+};
+
+export default makeCancelRunner;

new file
app/services/pipelines/getPipeline.ts
@@ -0,0 +1,22 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// auto-generated via script [prisma:generate]
+import type { Pipeline } from "@prisma/client";
+// app
+import type { PipelineServiceDeps } from "./types";
+
+const makeGetPipeline: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string],
+  Promise<Pipeline | null>
+> = ({ request }) => {
+  return async (pipelineId: string) => {
+    return request.prisma.pipeline.findUnique({
+      where: {
+        id: pipelineId,
+      },
+    });
+  };
+};
+
+export default makeGetPipeline;

new file
app/services/pipelines/getPipelineArtefacts.ts
@@ -0,0 +1,15 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeGetPipelineArtefacts: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string],
+  Promise<any>
+> = ({ runner }) => {
+  return async (pipelineId: string) => {
+    return runner.getPipelineArtefacts(pipelineId);
+  };
+};
+
+export default makeGetPipelineArtefacts;

new file
app/services/pipelines/getPipelineManifest.ts
@@ -0,0 +1,18 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeGetPipelineManifest: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string],
+  Promise<string | null>
+> = ({ request }) => {
+  return async (pipelineId: string) => {
+    const p = await request.prisma.pipeline.findUnique({
+      where: { id: pipelineId },
+    });
+    return p?.manifest ?? null;
+  };
+};
+
+export default makeGetPipelineManifest;

new file
app/services/pipelines/getPipelineStageLogs.ts
@@ -0,0 +1,18 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeGetPipelineStageLogs: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string, string],
+  Promise<string>
+> = ({ request }) => {
+  return async (pipelineId: string, stageId: string) => {
+    const stage = await request.prisma.stage.findFirst({
+      where: { id: stageId, pipelineId },
+    });
+    return stage?.logs ?? "";
+  };
+};
+
+export default makeGetPipelineStageLogs;

new file
app/services/pipelines/getPipelineStages.ts
@@ -0,0 +1,18 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeGetPipelineStages: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string],
+  Promise<any>
+> = ({ request }) => {
+  return async (pipelineId: string) => {
+    return request.prisma.stage.findMany({
+      where: { pipelineId },
+      orderBy: { order: "asc" },
+    });
+  };
+};
+
+export default makeGetPipelineStages;

new file
app/services/pipelines/getRepoPipelineManifest.ts
@@ -0,0 +1,25 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeGetRepoPipelineManifest: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string, string],
+  Promise<string | null>
+> = ({ request }) => {
+  return async (orgSlug: string, repoSlug: string) => {
+    const p = await request.prisma.pipeline.findFirst({
+      where: {
+        repo: {
+          organization: { slug: orgSlug },
+          slug: repoSlug,
+        },
+      },
+      orderBy: { createdAt: "desc" },
+    });
+    // todo: return a deserialized PipelineManifest
+    return p?.manifest ?? null;
+  };
+};
+
+export default makeGetRepoPipelineManifest;

new file
app/services/pipelines/index.ts
@@ -0,0 +1,41 @@
+// 1st-party
+import { makeService } from "@ethicdevs/react-monolith";
+// app
+// app service pipelines
+import type { PipelineServiceAPI, PipelineServiceDeps } from "./types";
+import { default as makeListByRepo } from "./listByRepo";
+import { default as makeListByRepoId } from "./listByRepoId";
+import { default as makeGetPipeline } from "./getPipeline";
+import { default as makeSetPipeline } from "./setPipeline";
+import { default as makeRmPipeline } from "./rmPipeline";
+import { default as makeParsePipelineManifest } from "./parsePipelineManifest";
+import { default as makeGetRepoPipelineManifest } from "./getRepoPipelineManifest";
+import { default as makeGetPipelineManifest } from "./getPipelineManifest";
+import { default as makeGetPipelineStages } from "./getPipelineStages";
+import { default as makeGetPipelineStageLogs } from "./getPipelineStageLogs";
+import { default as makeGetPipelineArtefacts } from "./getPipelineArtefacts";
+import { default as makeResetRunnerCache } from "./resetRunnerCache";
+import { default as makeInitRunnerForRepo } from "./initRunnerForRepo";
+import { default as makeTriggerRunner } from "./triggerRunner";
+import { default as makeCancelRunner } from "./cancelRunner";
+
+export const makePipelineService = makeService<
+  PipelineServiceAPI,
+  PipelineServiceDeps
+>({
+  listByRepo: makeListByRepo,
+  listByRepoId: makeListByRepoId,
+  getPipeline: makeGetPipeline,
+  setPipeline: makeSetPipeline,
+  rmPipeline: makeRmPipeline,
+  parsePipelineManifest: makeParsePipelineManifest,
+  getRepoPipelineManifest: makeGetRepoPipelineManifest,
+  getPipelineManifest: makeGetPipelineManifest,
+  getPipelineStages: makeGetPipelineStages,
+  getPipelineArtefacts: makeGetPipelineArtefacts,
+  getPipelineStageLogs: makeGetPipelineStageLogs,
+  resetRunnerCache: makeResetRunnerCache,
+  initRunnerForRepo: makeInitRunnerForRepo,
+  triggerRunner: makeTriggerRunner,
+  cancelRunner: makeCancelRunner,
+});

new file
app/services/pipelines/initRunnerForRepo.ts
@@ -0,0 +1,15 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeInitRunnerForRepo: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string, string, string],
+  Promise<any>
+> = ({ runner }) => {
+  return async (orgSlug: string, repoSlug: string, manifest: string) => {
+    return runner.initRepo(orgSlug, repoSlug, manifest);
+  };
+};
+
+export default makeInitRunnerForRepo;

new file
app/services/pipelines/listByRepo.ts
@@ -0,0 +1,26 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { Pipeline } from "@prisma/client";
+import type { PipelineServiceDeps } from "./types";
+
+const makeListByRepo: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string, string],
+  Promise<Pipeline[]>
+> = ({ request }) => {
+  return async (orgSlug: string, repoSlug: string) => {
+    return request.prisma.pipeline.findMany({
+      where: {
+        repo: {
+          organization: {
+            slug: orgSlug,
+          },
+          slug: repoSlug,
+        },
+      },
+      orderBy: { createdAt: "desc" },
+    });
+  };
+};
+
+export default makeListByRepo;

new file
app/services/pipelines/listByRepoId.ts
@@ -0,0 +1,19 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { Pipeline } from "@prisma/client";
+import type { PipelineServiceDeps } from "./types";
+
+const makeListByRepoId: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string],
+  Promise<Pipeline[]>
+> = ({ request }) => {
+  return async (repoId: string) => {
+    return request.prisma.pipeline.findMany({
+      where: { repoId },
+      orderBy: { createdAt: "desc" },
+    });
+  };
+};
+
+export default makeListByRepoId;

new file
app/services/pipelines/parsePipelineManifest.ts
@@ -0,0 +1,20 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { Manifest, PipelineServiceDeps } from "./types";
+
+const makeParsePipelineManifest: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string],
+  Promise<Manifest>
+> = () => {
+  return async (manifestJsonOrYml: string) => {
+    try {
+      const m = JSON.parse(manifestJsonOrYml);
+      return { manifest: manifestJsonOrYml, version: m.version ?? "1.0" };
+    } catch {
+      throw new Error("Unsupported manifest format. Provide JSON.");
+    }
+  };
+};
+
+export default makeParsePipelineManifest;

new file
app/services/pipelines/resetRunnerCache.ts
@@ -0,0 +1,15 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeResetRunnerCache: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string, string],
+  Promise<any>
+> = ({ runner }) => {
+  return async (orgSlug: string, repoSlug: string) => {
+    return runner.resetCache(orgSlug, repoSlug);
+  };
+};
+
+export default makeResetRunnerCache;

new file
app/services/pipelines/rmPipeline.ts
@@ -0,0 +1,20 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeRmPipeline: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string, string],
+  Promise<any>
+> = ({ request, runner }) => {
+  return async (pipelineId: string, reason: string) => {
+    await runner.rmPipeline(pipelineId, reason);
+
+    return request.prisma.pipeline.update({
+      where: { id: pipelineId },
+      data: { status: "CANCELED" as any },
+    });
+  };
+};
+
+export default makeRmPipeline;

new file
app/services/pipelines/setPipeline.ts
@@ -0,0 +1,19 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+import type { Pipeline } from "@prisma/client";
+
+const makeSetPipeline: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string, Partial<Pipeline>],
+  Promise<Pipeline>
+> = ({ request }) => {
+  return async (pipelineId: string, data: Partial<Pipeline>) => {
+    return request.prisma.pipeline.update({
+      where: { id: pipelineId },
+      data: data as any,
+    });
+  };
+};
+
+export default makeSetPipeline;

new file
app/services/pipelines/triggerRunner.ts
@@ -0,0 +1,15 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+import type { PipelineServiceDeps } from "./types";
+
+const makeTriggerRunner: ServiceMethodFactory<
+  PipelineServiceDeps,
+  [string, string, string],
+  Promise<any>
+> = ({ runner }) => {
+  return async (orgSlug: string, repoSlug: string, pipelineId: string) => {
+    return runner.triggerRun(orgSlug, repoSlug, pipelineId);
+  };
+};
+
+export default makeTriggerRunner;

new file
app/services/pipelines/types.ts
@@ -0,0 +1,45 @@
+// 3rd-party
+import type { FastifyRequest } from "fastify";
+// generated via script [prisma:generate]
+import type { Artefact, Pipeline, Stage } from "@prisma/client";
+
+// Lightweight shared types for pipeline domain to minimize coupling in MVP.
+export type Manifest = { manifest: string; version?: string };
+
+export type PipelineEntity = Pipeline;
+export type StageEntity = Stage;
+export type ArtefactEntity = Artefact;
+
+export interface PipelineServiceDeps {
+  request: FastifyRequest;
+  runner: any; // GitfossCIRunnerClient
+}
+
+export type PipelineServiceAPI = {
+  listByRepo(orgSlug: string, repoSlug: string): Promise<Pipeline[]>;
+  listByRepoId(repoId: string): Promise<Pipeline[]>;
+  getRepoPipelineManifest(orgSlug: string, repoSlug: string): Promise<Manifest>;
+  parsePipelineManifest(manifestJsonOrYml: string): Manifest;
+  getPipelineManifest(pipelineId: string): Promise<Manifest>;
+  getPipelineStages(pipelineId: string): Promise<Stage[]>;
+  getPipelineStageLogs(
+    pipelineId: string,
+    stageId: string,
+  ): Promise<Stage["logs"][]>;
+  getPipelineArtefacts(pipelineId: string): Promise<Artefact[]>;
+  getPipeline(pipelineId: string): Promise<Pipeline>;
+  setPipeline(pipelineId: string, data: Partial<Pipeline>): Promise<Pipeline>;
+  rmPipeline(pipelineId: string, reason: string): Promise<Pipeline>;
+  initRunnerForRepo(
+    orgSlug: string,
+    repoSlug: string,
+    manifest: string,
+  ): Promise<void>;
+  triggerRunner(
+    orgSlug: string,
+    repoSlug: string,
+    pipelineId: string,
+  ): Promise<Pipeline>;
+  cancelRunner(pipelineId: string): Promise<void>;
+  resetRunnerCache(orgSlug: string, repoSlug: string): Promise<void>;
+};

new file
db/migrations/20260513093800_add_pipelines_models/migration.sql
@@ -0,0 +1,53 @@
+-- CreateEnum
+CREATE TYPE "PipelineStatus" AS ENUM ('PENDING', 'RUNNING', 'PASSED', 'FAILED', 'CANCELED');
+
+-- CreateEnum
+CREATE TYPE "StageStatus" AS ENUM ('PENDING', 'RUNNING', 'PASSED', 'FAILED', 'CANCELED');
+
+-- CreateTable
+CREATE TABLE "Pipeline" (
+    "id" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "name" TEXT NOT NULL,
+    "status" "PipelineStatus" NOT NULL DEFAULT 'PENDING',
+    "manifest" TEXT,
+    "repoId" TEXT NOT NULL,
+
+    CONSTRAINT "Pipeline_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Stage" (
+    "id" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "name" TEXT NOT NULL,
+    "order" INTEGER NOT NULL,
+    "status" "StageStatus" NOT NULL DEFAULT 'PENDING',
+    "logs" TEXT,
+    "pipelineId" TEXT NOT NULL,
+
+    CONSTRAINT "Stage_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Artefact" (
+    "id" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "name" TEXT NOT NULL,
+    "path" TEXT NOT NULL,
+    "size" INTEGER,
+    "pipelineId" TEXT NOT NULL,
+
+    CONSTRAINT "Artefact_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "Pipeline" ADD CONSTRAINT "Pipeline_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Stage" ADD CONSTRAINT "Stage_pipelineId_fkey" FOREIGN KEY ("pipelineId") REFERENCES "Pipeline"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Artefact" ADD CONSTRAINT "Artefact_pipelineId_fkey" FOREIGN KEY ("pipelineId") REFERENCES "Pipeline"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

@@ -11,7 +11,7 @@ datasource db {
 
 model Organization {
   id      String @id @default(cuid())
-  
+
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
 

...
@@ -105,11 +105,12 @@ model Repository {
   shortDescription String?
   websiteUrl       String?
 
+  organization            Organization  @relation("ManyRepositoriesToOneOrganization", fields: [organizationId], references: [id])
   forkedFromRepo          Repository?   @relation("OneParentRepositoryToManyForkRepository", fields: [forkedFromRepoId], references: [id])
   forks                   Repository[]  @relation("OneParentRepositoryToManyForkRepository")
-  organization            Organization  @relation("ManyRepositoriesToOneOrganization", fields: [organizationId], references: [id])
   pullRequestsWhereSource PullRequest[] @relation("OneSourceRepositoryToManyPullRequests")
   pullRequestsWhereTarget PullRequest[] @relation("OneTargetRepositoryToManyPullRequests")
+  pipelines Pipeline[] @relation("RepositoryPipelines")
 
   @@unique([slug, organizationId])
 }

...
@@ -163,6 +164,48 @@ model UserSSHKey {
   revoked Boolean @default(false)
 }
 
+model Pipeline {
+  id        String      @id @default(cuid())
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  name      String
+  status    PipelineStatus @default(PENDING)
+  manifest  String?
+
+  repoId    String
+  repo      Repository @relation("RepositoryPipelines",fields: [repoId], references: [id])
+
+  stages    Stage[]    @relation("PipelineStages")
+  artefacts Artefact[] @relation("PipelineArtefacts")
+}
+
+model Stage {
+  id         String   @id @default(cuid())
+  createdAt  DateTime @default(now())
+  updatedAt  DateTime @updatedAt
+
+  name       String
+  order      Int
+  status     StageStatus @default(PENDING)
+  logs       String?
+
+  pipelineId String
+  pipeline   Pipeline @relation("PipelineStages", fields: [pipelineId], references: [id])
+}
+
+model Artefact {
+  id         String   @id @default(cuid())
+  createdAt  DateTime @default(now())
+
+  name       String
+  path       String
+  size       Int?
+
+  pipelineId String
+  pipeline   Pipeline @relation("PipelineArtefacts", fields: [pipelineId], references: [id])
+}
+
 enum GlobalRole {
   GUEST
   CUSTOMER

...
@@ -193,3 +236,19 @@ enum ResourceVisibility {
   UNLISTED
   PRIVATE
 }
+
+enum PipelineStatus {
+  PENDING
+  RUNNING
+  PASSED
+  FAILED
+  CANCELED
+}
+
+enum StageStatus {
+  PENDING
+  RUNNING
+  PASSED
+  FAILED
+  CANCELED
+}