Files
stack/apps/api/src/quality-gate-config/quality-gate-config.service.ts
Jason Woltje 4a2909ce1e feat(#135): implement Quality Gate Configuration System
Add database-backed quality gate configuration for workspaces with
full CRUD operations and default gate seeding.

Schema:
- Add QualityGate model with workspace relation
- Support for custom commands and regex patterns
- Enable/disable and ordering support

Service:
- CRUD operations for quality gates
- findEnabled: Get ordered, enabled gates
- reorder: Bulk reorder with transaction
- seedDefaults: Seed 4 default gates
- toOrchestratorFormat: Convert to orchestrator interface

Endpoints:
- GET /workspaces/:id/quality-gates - List
- GET /workspaces/:id/quality-gates/:gateId - Get one
- POST /workspaces/:id/quality-gates - Create
- PATCH /workspaces/:id/quality-gates/:gateId - Update
- DELETE /workspaces/:id/quality-gates/:gateId - Delete
- POST /workspaces/:id/quality-gates/reorder
- POST /workspaces/:id/quality-gates/seed-defaults

Default gates: Build, Lint, Test, Coverage (85%)

Tests: 25 passing with 95.16% coverage

Fixes #135

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 13:33:04 -06:00

238 lines
6.1 KiB
TypeScript

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;
});
}
}