import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import type { GiteaPrWebhookDto } from "./dto/gitea-pr-webhook.dto"; export interface ReviewResult { passed: boolean; issues: string[]; } @Injectable() export class GatekeeperService { private readonly logger = new Logger(GatekeeperService.name); private get giteaApiBaseUrl(): string { return `${this.configService.getOrThrow("GITEA_URL")}/api/v1`; } constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService ) {} async handlePrEvent(payload: GiteaPrWebhookDto): Promise { if (!this.isEnabled()) { return; } if (payload.type !== "pull_request") { return; } const action = payload.action; const hasAutoMergeLabel = this.hasAutoMergeLabel(payload); if (!["opened", "labeled", "synchronize"].includes(action)) { return; } if (action === "labeled" && payload.label?.name !== "auto-merge") { return; } const merge = await this.prisma.pendingMerge.upsert({ where: { repo_prNumber_headSha: { repo: payload.repository.full_name, prNumber: payload.pull_request.number, headSha: payload.pull_request.head.sha, }, }, create: { repo: payload.repository.full_name, prNumber: payload.pull_request.number, headSha: payload.pull_request.head.sha, ...(payload.sender?.login ? { requester: payload.sender.login } : {}), giteaMergeUrl: payload.pull_request.url ?? `${this.giteaApiBaseUrl}/repos/${payload.repository.full_name}/pulls/${String(payload.pull_request.number)}`, }, update: { headSha: payload.pull_request.head.sha, ...(payload.sender?.login ? { requester: payload.sender.login } : {}), giteaMergeUrl: payload.pull_request.url ?? `${this.giteaApiBaseUrl}/repos/${payload.repository.full_name}/pulls/${String(payload.pull_request.number)}`, }, }); if (action === "synchronize") { await this.prisma.pendingMerge.update({ where: { id: merge.id }, data: { state: hasAutoMergeLabel ? "pending" : "rejected", ciStatus: null, reviewResult: Prisma.DbNull, }, }); if (hasAutoMergeLabel) { await this.runReview(merge.id, payload); } return; } if (hasAutoMergeLabel) { await this.runReview(merge.id, payload); } } async handleCiEvent( repo: string, prNumber: number, headSha: string, status: "success" | "failure" ): Promise { if (!this.isEnabled()) { return; } const merge = await this.prisma.pendingMerge.findFirst({ where: { repo, prNumber, headSha, }, orderBy: { createdAt: "desc", }, }); if (!merge) { this.logger.debug(`No pending merge found for ${repo}#${String(prNumber)} @ ${headSha}`); return; } await this.prisma.pendingMerge.update({ where: { id: merge.id }, data: { ciStatus: status, }, }); if (status === "failure") { await this.rejectMerge(merge.id, "CI reported failure"); return; } if (merge.state === "awaiting_ci") { await this.attemptMerge(merge.id); } } reviewPr(payload: GiteaPrWebhookDto): Promise { const issues: string[] = []; if (!this.hasAutoMergeLabel(payload)) { issues.push("PR must have the auto-merge label"); } if (!payload.pull_request.body?.trim()) { issues.push("PR description must not be empty"); } if (payload.pull_request.base.ref !== "main") { issues.push("PR base branch must be main"); } if (!/^[0-9a-f]{7,128}$/i.test(payload.pull_request.head.sha)) { issues.push("PR head SHA must be a valid git commit hash"); } return Promise.resolve({ passed: issues.length === 0, issues, }); } async attemptMerge(mergeId: string): Promise { const merge = await this.prisma.pendingMerge.findUnique({ where: { id: mergeId }, }); if (!merge) { return; } const token = this.configService.get("GITEA_API_TOKEN"); if (!token) { await this.rejectMerge(merge.id, "GITEA_API_TOKEN is not configured"); return; } await this.prisma.pendingMerge.update({ where: { id: merge.id }, data: { state: "merging" }, }); const mergeUrl = merge.giteaMergeUrl ?? `${this.giteaApiBaseUrl}/repos/${merge.repo}/pulls/${String(merge.prNumber)}`; const response = await fetch(`${mergeUrl}/merge`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `token ${token}`, }, body: JSON.stringify({ Do: "merge", force_merge: true, merge_message_field: "Auto-merged by Gatekeeper", }), }); if (!response.ok) { const reason = await response.text(); await this.rejectMerge( merge.id, `Gitea merge API rejected the request: ${String(response.status)} ${reason}` ); return; } await this.prisma.pendingMerge.update({ where: { id: merge.id }, data: { state: "merged" }, }); } async rejectMerge(mergeId: string, reason: string): Promise { const merge = await this.prisma.pendingMerge.findUnique({ where: { id: mergeId }, }); if (!merge) { return; } await this.prisma.pendingMerge.update({ where: { id: merge.id }, data: { state: "rejected", reviewResult: { passed: false, issues: [reason], }, }, }); await this.postPullRequestComment( merge.repo, merge.prNumber, `Gatekeeper rejected auto-merge for \`${merge.headSha}\`: ${reason}` ); } private async runReview(mergeId: string, payload: GiteaPrWebhookDto): Promise { await this.prisma.pendingMerge.update({ where: { id: mergeId }, data: { state: "reviewing" }, }); const result = await this.reviewPr(payload); if (!result.passed) { await this.rejectMerge(mergeId, result.issues.join("; ")); return; } const reviewResult: Prisma.InputJsonValue = { passed: result.passed, issues: result.issues, }; await this.prisma.pendingMerge.update({ where: { id: mergeId }, data: { state: "awaiting_ci", reviewResult, }, }); } private hasAutoMergeLabel(payload: GiteaPrWebhookDto): boolean { return payload.pull_request.labels.some((label) => label.name === "auto-merge"); } private async postPullRequestComment( repo: string, prNumber: number, body: string ): Promise { const token = this.configService.get("GITEA_API_TOKEN"); if (!token) { this.logger.warn( `Skipping PR comment for ${repo}#${String(prNumber)}; GITEA_API_TOKEN is missing` ); return; } const response = await fetch( `${this.giteaApiBaseUrl}/repos/${repo}/issues/${String(prNumber)}/comments`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `token ${token}`, }, body: JSON.stringify({ body }), } ); if (!response.ok) { this.logger.warn( `Failed to post Gatekeeper PR comment for ${repo}#${String(prNumber)}: ${String(response.status)}` ); } } private isEnabled(): boolean { const raw = this.configService.get("GATEKEEPER_ENABLED"); const enabled = raw !== "false"; if (!enabled) { this.logger.warn("Gatekeeper is disabled via GATEKEEPER_ENABLED"); } return enabled; } }