312 lines
7.8 KiB
TypeScript
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;
|
|
}
|
|
}
|