Files
stack/apps/api/src/gatekeeper/gatekeeper.service.ts
Jason Woltje 5f0a7c847c
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
fix: use double quotes for ConfigService key (prettier)
2026-03-10 22:50:58 -05:00

312 lines
7.8 KiB
TypeScript

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<string>("GITEA_URL")}/api/v1`;
}
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService
) {}
async handlePrEvent(payload: GiteaPrWebhookDto): Promise<void> {
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<void> {
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<ReviewResult> {
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<void> {
const merge = await this.prisma.pendingMerge.findUnique({
where: { id: mergeId },
});
if (!merge) {
return;
}
const token = this.configService.get<string>("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<void> {
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<void> {
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<void> {
const token = this.configService.get<string>("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<string>("GATEKEEPER_ENABLED");
const enabled = raw !== "false";
if (!enabled) {
this.logger.warn("Gatekeeper is disabled via GATEKEEPER_ENABLED");
}
return enabled;
}
}