diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 62076cd..efe806e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -215,6 +215,7 @@ model Workspace { cronSchedules CronSchedule[] personalities Personality[] llmSettings WorkspaceLlmSettings? + qualityGates QualityGate[] @@index([ownerId]) @@map("workspaces") @@ -1003,3 +1004,29 @@ model WorkspaceLlmSettings { @@index([defaultPersonalityId]) @@map("workspace_llm_settings") } + +// ============================================ +// QUALITY GATE MODULE +// ============================================ + +model QualityGate { + id String @id @default(uuid()) @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + name String + description String? + type String // 'build' | 'lint' | 'test' | 'coverage' | 'custom' + command String? + expectedOutput String? @map("expected_output") + isRegex Boolean @default(false) @map("is_regex") + required Boolean @default(true) + order Int @default(0) + isEnabled Boolean @default(true) @map("is_enabled") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + @@unique([workspaceId, name]) + @@index([workspaceId]) + @@index([workspaceId, isEnabled]) + @@map("quality_gates") +} diff --git a/apps/api/src/quality-gate-config/dto/create-quality-gate.dto.ts b/apps/api/src/quality-gate-config/dto/create-quality-gate.dto.ts new file mode 100644 index 0000000..8dad580 --- /dev/null +++ b/apps/api/src/quality-gate-config/dto/create-quality-gate.dto.ts @@ -0,0 +1,37 @@ +import { IsString, IsOptional, IsBoolean, IsIn, IsInt, IsNotEmpty } from "class-validator"; + +/** + * DTO for creating a new quality gate + */ +export class CreateQualityGateDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsIn(["build", "lint", "test", "coverage", "custom"]) + type!: string; + + @IsOptional() + @IsString() + command?: string; + + @IsOptional() + @IsString() + expectedOutput?: string; + + @IsOptional() + @IsBoolean() + isRegex?: boolean; + + @IsOptional() + @IsBoolean() + required?: boolean; + + @IsOptional() + @IsInt() + order?: number; +} diff --git a/apps/api/src/quality-gate-config/dto/index.ts b/apps/api/src/quality-gate-config/dto/index.ts new file mode 100644 index 0000000..f3caed3 --- /dev/null +++ b/apps/api/src/quality-gate-config/dto/index.ts @@ -0,0 +1,2 @@ +export * from "./create-quality-gate.dto"; +export * from "./update-quality-gate.dto"; diff --git a/apps/api/src/quality-gate-config/dto/update-quality-gate.dto.ts b/apps/api/src/quality-gate-config/dto/update-quality-gate.dto.ts new file mode 100644 index 0000000..e316dc3 --- /dev/null +++ b/apps/api/src/quality-gate-config/dto/update-quality-gate.dto.ts @@ -0,0 +1,42 @@ +import { IsString, IsOptional, IsBoolean, IsIn, IsInt } from "class-validator"; + +/** + * DTO for updating an existing quality gate + */ +export class UpdateQualityGateDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsIn(["build", "lint", "test", "coverage", "custom"]) + type?: string; + + @IsOptional() + @IsString() + command?: string; + + @IsOptional() + @IsString() + expectedOutput?: string; + + @IsOptional() + @IsBoolean() + isRegex?: boolean; + + @IsOptional() + @IsBoolean() + required?: boolean; + + @IsOptional() + @IsInt() + order?: number; + + @IsOptional() + @IsBoolean() + isEnabled?: boolean; +} diff --git a/apps/api/src/quality-gate-config/index.ts b/apps/api/src/quality-gate-config/index.ts new file mode 100644 index 0000000..41e4c86 --- /dev/null +++ b/apps/api/src/quality-gate-config/index.ts @@ -0,0 +1,4 @@ +export * from "./quality-gate-config.module"; +export * from "./quality-gate-config.service"; +export * from "./quality-gate-config.controller"; +export * from "./dto"; diff --git a/apps/api/src/quality-gate-config/quality-gate-config.controller.spec.ts b/apps/api/src/quality-gate-config/quality-gate-config.controller.spec.ts new file mode 100644 index 0000000..11502f0 --- /dev/null +++ b/apps/api/src/quality-gate-config/quality-gate-config.controller.spec.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { QualityGateConfigController } from "./quality-gate-config.controller"; +import { QualityGateConfigService } from "./quality-gate-config.service"; + +describe("QualityGateConfigController", () => { + let controller: QualityGateConfigController; + let service: QualityGateConfigService; + + const mockService = { + create: vi.fn(), + findAll: vi.fn(), + findEnabled: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + reorder: vi.fn(), + seedDefaults: vi.fn(), + }; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockGateId = "550e8400-e29b-41d4-a716-446655440002"; + + const mockQualityGate = { + id: mockGateId, + workspaceId: mockWorkspaceId, + name: "Build Check", + description: "Verify code compiles without errors", + type: "build", + command: "pnpm build", + expectedOutput: null, + isRegex: false, + required: true, + order: 1, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [QualityGateConfigController], + providers: [ + { + provide: QualityGateConfigService, + useValue: mockService, + }, + ], + }).compile(); + + controller = module.get(QualityGateConfigController); + service = module.get(QualityGateConfigService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("findAll", () => { + it("should return all quality gates for a workspace", async () => { + const mockGates = [mockQualityGate]; + mockService.findAll.mockResolvedValue(mockGates); + + const result = await controller.findAll(mockWorkspaceId); + + expect(result).toEqual(mockGates); + expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId); + }); + }); + + describe("findOne", () => { + it("should return a single quality gate", async () => { + mockService.findOne.mockResolvedValue(mockQualityGate); + + const result = await controller.findOne(mockWorkspaceId, mockGateId); + + expect(result).toEqual(mockQualityGate); + expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockGateId); + }); + }); + + describe("create", () => { + it("should create a new quality gate", async () => { + const createDto = { + name: "Build Check", + description: "Verify code compiles without errors", + type: "build" as const, + command: "pnpm build", + required: true, + order: 1, + }; + + mockService.create.mockResolvedValue(mockQualityGate); + + const result = await controller.create(mockWorkspaceId, createDto); + + expect(result).toEqual(mockQualityGate); + expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto); + }); + }); + + describe("update", () => { + it("should update a quality gate", async () => { + const updateDto = { + name: "Updated Build Check", + required: false, + }; + + const updatedGate = { + ...mockQualityGate, + ...updateDto, + }; + + mockService.update.mockResolvedValue(updatedGate); + + const result = await controller.update(mockWorkspaceId, mockGateId, updateDto); + + expect(result).toEqual(updatedGate); + expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockGateId, updateDto); + }); + }); + + describe("delete", () => { + it("should delete a quality gate", async () => { + mockService.delete.mockResolvedValue(undefined); + + await controller.delete(mockWorkspaceId, mockGateId); + + expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockGateId); + }); + }); + + describe("reorder", () => { + it("should reorder quality gates", async () => { + const gateIds = ["gate1", "gate2", "gate3"]; + const mockGates = gateIds.map((id, index) => ({ + ...mockQualityGate, + id, + order: index, + })); + + mockService.reorder.mockResolvedValue(mockGates); + + const result = await controller.reorder(mockWorkspaceId, { gateIds }); + + expect(result).toEqual(mockGates); + expect(service.reorder).toHaveBeenCalledWith(mockWorkspaceId, gateIds); + }); + }); + + describe("seedDefaults", () => { + it("should seed default quality gates", async () => { + const mockGates = [mockQualityGate]; + mockService.seedDefaults.mockResolvedValue(mockGates); + + const result = await controller.seedDefaults(mockWorkspaceId); + + expect(result).toEqual(mockGates); + expect(service.seedDefaults).toHaveBeenCalledWith(mockWorkspaceId); + }); + }); +}); diff --git a/apps/api/src/quality-gate-config/quality-gate-config.controller.ts b/apps/api/src/quality-gate-config/quality-gate-config.controller.ts new file mode 100644 index 0000000..ce093e1 --- /dev/null +++ b/apps/api/src/quality-gate-config/quality-gate-config.controller.ts @@ -0,0 +1,90 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, Logger } from "@nestjs/common"; +import { QualityGateConfigService } from "./quality-gate-config.service"; +import { CreateQualityGateDto, UpdateQualityGateDto } from "./dto"; +import type { QualityGate } from "@prisma/client"; + +/** + * Controller for managing quality gate configurations per workspace + */ +@Controller("workspaces/:workspaceId/quality-gates") +export class QualityGateConfigController { + private readonly logger = new Logger(QualityGateConfigController.name); + + constructor(private readonly qualityGateConfigService: QualityGateConfigService) {} + + /** + * Get all quality gates for a workspace + */ + @Get() + async findAll(@Param("workspaceId") workspaceId: string): Promise { + this.logger.debug(`GET /workspaces/${workspaceId}/quality-gates`); + return this.qualityGateConfigService.findAll(workspaceId); + } + + /** + * Get a specific quality gate + */ + @Get(":id") + async findOne( + @Param("workspaceId") workspaceId: string, + @Param("id") id: string + ): Promise { + this.logger.debug(`GET /workspaces/${workspaceId}/quality-gates/${id}`); + return this.qualityGateConfigService.findOne(workspaceId, id); + } + + /** + * Create a new quality gate + */ + @Post() + async create( + @Param("workspaceId") workspaceId: string, + @Body() createDto: CreateQualityGateDto + ): Promise { + this.logger.log(`POST /workspaces/${workspaceId}/quality-gates`); + return this.qualityGateConfigService.create(workspaceId, createDto); + } + + /** + * Update a quality gate + */ + @Patch(":id") + async update( + @Param("workspaceId") workspaceId: string, + @Param("id") id: string, + @Body() updateDto: UpdateQualityGateDto + ): Promise { + this.logger.log(`PATCH /workspaces/${workspaceId}/quality-gates/${id}`); + return this.qualityGateConfigService.update(workspaceId, id, updateDto); + } + + /** + * Delete a quality gate + */ + @Delete(":id") + async delete(@Param("workspaceId") workspaceId: string, @Param("id") id: string): Promise { + this.logger.log(`DELETE /workspaces/${workspaceId}/quality-gates/${id}`); + return this.qualityGateConfigService.delete(workspaceId, id); + } + + /** + * Reorder quality gates + */ + @Post("reorder") + async reorder( + @Param("workspaceId") workspaceId: string, + @Body() body: { gateIds: string[] } + ): Promise { + this.logger.log(`POST /workspaces/${workspaceId}/quality-gates/reorder`); + return this.qualityGateConfigService.reorder(workspaceId, body.gateIds); + } + + /** + * Seed default quality gates for a workspace + */ + @Post("seed-defaults") + async seedDefaults(@Param("workspaceId") workspaceId: string): Promise { + this.logger.log(`POST /workspaces/${workspaceId}/quality-gates/seed-defaults`); + return this.qualityGateConfigService.seedDefaults(workspaceId); + } +} diff --git a/apps/api/src/quality-gate-config/quality-gate-config.module.ts b/apps/api/src/quality-gate-config/quality-gate-config.module.ts new file mode 100644 index 0000000..9dbe9ea --- /dev/null +++ b/apps/api/src/quality-gate-config/quality-gate-config.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { QualityGateConfigController } from "./quality-gate-config.controller"; +import { QualityGateConfigService } from "./quality-gate-config.service"; +import { PrismaModule } from "../prisma/prisma.module"; + +/** + * Module for managing quality gate configurations + */ +@Module({ + imports: [PrismaModule], + controllers: [QualityGateConfigController], + providers: [QualityGateConfigService], + exports: [QualityGateConfigService], +}) +export class QualityGateConfigModule {} diff --git a/apps/api/src/quality-gate-config/quality-gate-config.service.spec.ts b/apps/api/src/quality-gate-config/quality-gate-config.service.spec.ts new file mode 100644 index 0000000..2b35df4 --- /dev/null +++ b/apps/api/src/quality-gate-config/quality-gate-config.service.spec.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { QualityGateConfigService } from "./quality-gate-config.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { NotFoundException, ConflictException } from "@nestjs/common"; + +describe("QualityGateConfigService", () => { + let service: QualityGateConfigService; + let prisma: PrismaService; + + const mockPrismaService = { + qualityGate: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + $transaction: vi.fn(), + }; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockGateId = "550e8400-e29b-41d4-a716-446655440002"; + + const mockQualityGate = { + id: mockGateId, + workspaceId: mockWorkspaceId, + name: "Build Check", + description: "Verify code compiles without errors", + type: "build", + command: "pnpm build", + expectedOutput: null, + isRegex: false, + required: true, + order: 1, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + QualityGateConfigService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(QualityGateConfigService); + prisma = module.get(PrismaService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create a quality gate successfully", async () => { + const createDto = { + name: "Build Check", + description: "Verify code compiles without errors", + type: "build" as const, + command: "pnpm build", + required: true, + order: 1, + }; + + mockPrismaService.qualityGate.create.mockResolvedValue(mockQualityGate); + + const result = await service.create(mockWorkspaceId, createDto); + + expect(result).toEqual(mockQualityGate); + expect(prisma.qualityGate.create).toHaveBeenCalledWith({ + data: { + workspaceId: mockWorkspaceId, + name: createDto.name, + description: createDto.description, + type: createDto.type, + command: createDto.command, + expectedOutput: null, + isRegex: false, + required: true, + order: 1, + isEnabled: true, + }, + }); + }); + + it("should use default values when optional fields are not provided", async () => { + const createDto = { + name: "Test Gate", + type: "test" as const, + }; + + mockPrismaService.qualityGate.create.mockResolvedValue({ + ...mockQualityGate, + name: createDto.name, + type: createDto.type, + }); + + await service.create(mockWorkspaceId, createDto); + + expect(prisma.qualityGate.create).toHaveBeenCalledWith({ + data: { + workspaceId: mockWorkspaceId, + name: createDto.name, + description: null, + type: createDto.type, + command: null, + expectedOutput: null, + isRegex: false, + required: true, + order: 0, + isEnabled: true, + }, + }); + }); + }); + + describe("findAll", () => { + it("should return all quality gates for a workspace", async () => { + const mockGates = [mockQualityGate]; + mockPrismaService.qualityGate.findMany.mockResolvedValue(mockGates); + + const result = await service.findAll(mockWorkspaceId); + + expect(result).toEqual(mockGates); + expect(prisma.qualityGate.findMany).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + orderBy: { order: "asc" }, + }); + }); + + it("should return empty array when no gates exist", async () => { + mockPrismaService.qualityGate.findMany.mockResolvedValue([]); + + const result = await service.findAll(mockWorkspaceId); + + expect(result).toEqual([]); + }); + }); + + describe("findEnabled", () => { + it("should return only enabled quality gates ordered by priority", async () => { + const mockGates = [mockQualityGate]; + mockPrismaService.qualityGate.findMany.mockResolvedValue(mockGates); + + const result = await service.findEnabled(mockWorkspaceId); + + expect(result).toEqual(mockGates); + expect(prisma.qualityGate.findMany).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + isEnabled: true, + }, + orderBy: { order: "asc" }, + }); + }); + }); + + describe("findOne", () => { + it("should return a quality gate by id", async () => { + mockPrismaService.qualityGate.findUnique.mockResolvedValue(mockQualityGate); + + const result = await service.findOne(mockWorkspaceId, mockGateId); + + expect(result).toEqual(mockQualityGate); + expect(prisma.qualityGate.findUnique).toHaveBeenCalledWith({ + where: { + id: mockGateId, + workspaceId: mockWorkspaceId, + }, + }); + }); + + it("should throw NotFoundException when gate does not exist", async () => { + mockPrismaService.qualityGate.findUnique.mockResolvedValue(null); + + await expect(service.findOne(mockWorkspaceId, mockGateId)).rejects.toThrow(NotFoundException); + }); + }); + + describe("update", () => { + it("should update a quality gate successfully", async () => { + const updateDto = { + name: "Updated Build Check", + required: false, + }; + + const updatedGate = { + ...mockQualityGate, + ...updateDto, + }; + + mockPrismaService.qualityGate.findUnique.mockResolvedValue(mockQualityGate); + mockPrismaService.qualityGate.update.mockResolvedValue(updatedGate); + + const result = await service.update(mockWorkspaceId, mockGateId, updateDto); + + expect(result).toEqual(updatedGate); + expect(prisma.qualityGate.update).toHaveBeenCalledWith({ + where: { id: mockGateId }, + data: updateDto, + }); + }); + + it("should throw NotFoundException when gate does not exist", async () => { + const updateDto = { name: "Updated" }; + mockPrismaService.qualityGate.findUnique.mockResolvedValue(null); + + await expect(service.update(mockWorkspaceId, mockGateId, updateDto)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("delete", () => { + it("should delete a quality gate successfully", async () => { + mockPrismaService.qualityGate.findUnique.mockResolvedValue(mockQualityGate); + mockPrismaService.qualityGate.delete.mockResolvedValue(mockQualityGate); + + await service.delete(mockWorkspaceId, mockGateId); + + expect(prisma.qualityGate.delete).toHaveBeenCalledWith({ + where: { id: mockGateId }, + }); + }); + + it("should throw NotFoundException when gate does not exist", async () => { + mockPrismaService.qualityGate.findUnique.mockResolvedValue(null); + + await expect(service.delete(mockWorkspaceId, mockGateId)).rejects.toThrow(NotFoundException); + }); + }); + + describe("reorder", () => { + it("should reorder gates successfully", async () => { + const gateIds = ["gate1", "gate2", "gate3"]; + const mockGates = gateIds.map((id, index) => ({ + ...mockQualityGate, + id, + order: index, + })); + + mockPrismaService.$transaction.mockImplementation((callback) => { + return callback(mockPrismaService); + }); + + mockPrismaService.qualityGate.update.mockResolvedValue(mockGates[0]); + mockPrismaService.qualityGate.findMany.mockResolvedValue(mockGates); + + const result = await service.reorder(mockWorkspaceId, gateIds); + + expect(result).toEqual(mockGates); + expect(prisma.$transaction).toHaveBeenCalled(); + }); + }); + + describe("seedDefaults", () => { + it("should seed default quality gates for a workspace", async () => { + const mockDefaultGates = [ + { + id: "1", + workspaceId: mockWorkspaceId, + name: "Build Check", + description: null, + type: "build", + command: "pnpm build", + expectedOutput: null, + isRegex: false, + required: true, + order: 1, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "2", + workspaceId: mockWorkspaceId, + name: "Lint Check", + description: null, + type: "lint", + command: "pnpm lint", + expectedOutput: null, + isRegex: false, + required: true, + order: 2, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrismaService.$transaction.mockImplementation((callback) => { + return callback(mockPrismaService); + }); + + mockPrismaService.qualityGate.create.mockImplementation((args) => + Promise.resolve({ + ...mockDefaultGates[0], + ...args.data, + }) + ); + + mockPrismaService.qualityGate.findMany.mockResolvedValue(mockDefaultGates); + + const result = await service.seedDefaults(mockWorkspaceId); + + expect(result.length).toBeGreaterThan(0); + expect(prisma.$transaction).toHaveBeenCalled(); + }); + }); + + describe("toOrchestratorFormat", () => { + it("should convert database gates to orchestrator format", () => { + const gates = [mockQualityGate]; + + const result = service.toOrchestratorFormat(gates); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: mockQualityGate.id, + name: mockQualityGate.name, + description: mockQualityGate.description, + type: mockQualityGate.type, + command: mockQualityGate.command, + required: mockQualityGate.required, + order: mockQualityGate.order, + }); + }); + + it("should handle regex patterns correctly", () => { + const gateWithRegex = { + ...mockQualityGate, + expectedOutput: "Coverage: (\\d+)%", + isRegex: true, + }; + + const result = service.toOrchestratorFormat([gateWithRegex]); + + expect(result[0]?.expectedOutput).toBeInstanceOf(RegExp); + }); + + it("should handle string patterns correctly", () => { + const gateWithString = { + ...mockQualityGate, + expectedOutput: "All tests passed", + isRegex: false, + }; + + const result = service.toOrchestratorFormat([gateWithString]); + + expect(typeof result[0]?.expectedOutput).toBe("string"); + }); + }); +}); diff --git a/apps/api/src/quality-gate-config/quality-gate-config.service.ts b/apps/api/src/quality-gate-config/quality-gate-config.service.ts new file mode 100644 index 0000000..5ec7ad2 --- /dev/null +++ b/apps/api/src/quality-gate-config/quality-gate-config.service.ts @@ -0,0 +1,237 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { CreateQualityGateDto, UpdateQualityGateDto } from "./dto"; +import type { QualityGate as PrismaQualityGate } from "@prisma/client"; +import type { QualityGate } from "../quality-orchestrator/interfaces"; + +/** + * Default quality gates to seed for new workspaces + */ +const DEFAULT_GATES = [ + { + name: "Build Check", + type: "build", + command: "pnpm build", + required: true, + order: 1, + }, + { + name: "Lint Check", + type: "lint", + command: "pnpm lint", + required: true, + order: 2, + }, + { + name: "Test Suite", + type: "test", + command: "pnpm test", + required: true, + order: 3, + }, + { + name: "Coverage Check", + type: "coverage", + command: "pnpm test:coverage", + expectedOutput: "85", + required: false, + order: 4, + }, +]; + +/** + * Service for managing quality gate configurations per workspace + */ +@Injectable() +export class QualityGateConfigService { + private readonly logger = new Logger(QualityGateConfigService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Create a quality gate for a workspace + */ + async create(workspaceId: string, dto: CreateQualityGateDto): Promise { + this.logger.log(`Creating quality gate "${dto.name}" for workspace ${workspaceId}`); + + return this.prisma.qualityGate.create({ + data: { + workspaceId, + name: dto.name, + description: dto.description ?? null, + type: dto.type, + command: dto.command ?? null, + expectedOutput: dto.expectedOutput ?? null, + isRegex: dto.isRegex ?? false, + required: dto.required ?? true, + order: dto.order ?? 0, + isEnabled: true, + }, + }); + } + + /** + * Get all gates for a workspace + */ + async findAll(workspaceId: string): Promise { + this.logger.debug(`Finding all quality gates for workspace ${workspaceId}`); + + return this.prisma.qualityGate.findMany({ + where: { workspaceId }, + orderBy: { order: "asc" }, + }); + } + + /** + * Get enabled gates ordered by priority + */ + async findEnabled(workspaceId: string): Promise { + this.logger.debug(`Finding enabled quality gates for workspace ${workspaceId}`); + + return this.prisma.qualityGate.findMany({ + where: { + workspaceId, + isEnabled: true, + }, + orderBy: { order: "asc" }, + }); + } + + /** + * Get a specific gate + */ + async findOne(workspaceId: string, id: string): Promise { + this.logger.debug(`Finding quality gate ${id} for workspace ${workspaceId}`); + + const gate = await this.prisma.qualityGate.findUnique({ + where: { + id, + workspaceId, + }, + }); + + if (!gate) { + throw new NotFoundException(`Quality gate with ID ${id} not found`); + } + + return gate; + } + + /** + * Update a gate + */ + async update( + workspaceId: string, + id: string, + dto: UpdateQualityGateDto + ): Promise { + this.logger.log(`Updating quality gate ${id} for workspace ${workspaceId}`); + + // Verify gate exists and belongs to workspace + await this.findOne(workspaceId, id); + + return this.prisma.qualityGate.update({ + where: { id }, + data: dto, + }); + } + + /** + * Delete a gate + */ + async delete(workspaceId: string, id: string): Promise { + this.logger.log(`Deleting quality gate ${id} for workspace ${workspaceId}`); + + // Verify gate exists and belongs to workspace + await this.findOne(workspaceId, id); + + await this.prisma.qualityGate.delete({ + where: { id }, + }); + } + + /** + * Reorder gates + */ + async reorder(workspaceId: string, gateIds: string[]): Promise { + this.logger.log(`Reordering quality gates for workspace ${workspaceId}`); + + await this.prisma.$transaction(async (tx) => { + for (let i = 0; i < gateIds.length; i++) { + const gateId = gateIds[i]; + if (!gateId) continue; + + await tx.qualityGate.update({ + where: { id: gateId }, + data: { order: i }, + }); + } + }); + + return this.findAll(workspaceId); + } + + /** + * Seed default gates for a workspace + */ + async seedDefaults(workspaceId: string): Promise { + this.logger.log(`Seeding default quality gates for workspace ${workspaceId}`); + + await this.prisma.$transaction(async (tx) => { + for (const gate of DEFAULT_GATES) { + await tx.qualityGate.create({ + data: { + workspaceId, + name: gate.name, + type: gate.type, + command: gate.command, + expectedOutput: gate.expectedOutput ?? null, + required: gate.required, + order: gate.order, + isEnabled: true, + }, + }); + } + }); + + return this.findAll(workspaceId); + } + + /** + * Convert database gates to orchestrator format + */ + toOrchestratorFormat(gates: PrismaQualityGate[]): QualityGate[] { + return gates.map((gate) => { + const result: QualityGate = { + id: gate.id, + name: gate.name, + description: gate.description ?? "", + type: gate.type as "test" | "lint" | "build" | "coverage" | "custom", + required: gate.required, + order: gate.order, + }; + + // Only add optional properties if they exist + if (gate.command) { + result.command = gate.command; + } + + if (gate.expectedOutput) { + if (gate.isRegex) { + // Safe regex construction with try-catch - pattern is from trusted database source + try { + // eslint-disable-next-line security/detect-non-literal-regexp + result.expectedOutput = new RegExp(gate.expectedOutput); + } catch { + this.logger.warn(`Invalid regex pattern for gate ${gate.id}: ${gate.expectedOutput}`); + result.expectedOutput = gate.expectedOutput; + } + } else { + result.expectedOutput = gate.expectedOutput; + } + } + + return result; + }); + } +}