Release: CI/CD Pipeline & Architecture Updates #177
@@ -215,6 +215,7 @@ model Workspace {
|
|||||||
cronSchedules CronSchedule[]
|
cronSchedules CronSchedule[]
|
||||||
personalities Personality[]
|
personalities Personality[]
|
||||||
llmSettings WorkspaceLlmSettings?
|
llmSettings WorkspaceLlmSettings?
|
||||||
|
qualityGates QualityGate[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("workspaces")
|
@@map("workspaces")
|
||||||
@@ -1003,3 +1004,29 @@ model WorkspaceLlmSettings {
|
|||||||
@@index([defaultPersonalityId])
|
@@index([defaultPersonalityId])
|
||||||
@@map("workspace_llm_settings")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
2
apps/api/src/quality-gate-config/dto/index.ts
Normal file
2
apps/api/src/quality-gate-config/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./create-quality-gate.dto";
|
||||||
|
export * from "./update-quality-gate.dto";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
4
apps/api/src/quality-gate-config/index.ts
Normal file
4
apps/api/src/quality-gate-config/index.ts
Normal file
@@ -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";
|
||||||
@@ -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>(QualityGateConfigController);
|
||||||
|
service = module.get<QualityGateConfigService>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<QualityGate[]> {
|
||||||
|
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<QualityGate> {
|
||||||
|
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<QualityGate> {
|
||||||
|
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<QualityGate> {
|
||||||
|
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<void> {
|
||||||
|
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<QualityGate[]> {
|
||||||
|
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<QualityGate[]> {
|
||||||
|
this.logger.log(`POST /workspaces/${workspaceId}/quality-gates/seed-defaults`);
|
||||||
|
return this.qualityGateConfigService.seedDefaults(workspaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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>(QualityGateConfigService);
|
||||||
|
prisma = module.get<PrismaService>(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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
237
apps/api/src/quality-gate-config/quality-gate-config.service.ts
Normal file
237
apps/api/src/quality-gate-config/quality-gate-config.service.ts
Normal file
@@ -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<PrismaQualityGate> {
|
||||||
|
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<PrismaQualityGate[]> {
|
||||||
|
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<PrismaQualityGate[]> {
|
||||||
|
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<PrismaQualityGate> {
|
||||||
|
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<PrismaQualityGate> {
|
||||||
|
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<void> {
|
||||||
|
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<PrismaQualityGate[]> {
|
||||||
|
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<PrismaQualityGate[]> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user