feat(gatekeeper): add PR merge automation service
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
This commit is contained in:
@@ -24,6 +24,11 @@ ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
# In development, a random key is generated if not set
|
||||
CSRF_SECRET=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210
|
||||
|
||||
# Gatekeeper merge automation
|
||||
GITEA_WEBHOOK_SECRET=replace-with-gitea-webhook-secret
|
||||
GITEA_API_TOKEN=replace-with-gitea-api-token
|
||||
GATEKEEPER_ENABLED=true
|
||||
|
||||
# OpenTelemetry Configuration
|
||||
# Enable/disable OpenTelemetry tracing (default: true)
|
||||
OTEL_ENABLED=true
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"lint:fix": "eslint \"src/**/*.ts\" --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test": "node scripts/vitest-runner.mjs",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "vitest run --config ./vitest.e2e.config.ts",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "pending_merges" (
|
||||
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"repo" TEXT NOT NULL,
|
||||
"pr_number" INTEGER NOT NULL,
|
||||
"head_sha" TEXT NOT NULL,
|
||||
"state" TEXT NOT NULL DEFAULT 'pending',
|
||||
"review_result" JSONB,
|
||||
"ci_status" TEXT,
|
||||
"requester" TEXT,
|
||||
"gitea_merge_url" TEXT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "pending_merges_repo_pr_sha_key" UNIQUE ("repo", "pr_number", "head_sha")
|
||||
);
|
||||
@@ -261,6 +261,23 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model PendingMerge {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
repo String
|
||||
prNumber Int @map("pr_number")
|
||||
headSha String @map("head_sha")
|
||||
state String @default("pending")
|
||||
reviewResult Json? @map("review_result")
|
||||
ciStatus String? @map("ci_status")
|
||||
requester String?
|
||||
giteaMergeUrl String? @map("gitea_merge_url")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
@@unique([repo, prNumber, headSha])
|
||||
@@map("pending_merges")
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @unique @map("user_id") @db.Uuid
|
||||
|
||||
36
apps/api/scripts/vitest-runner.mjs
Normal file
36
apps/api/scripts/vitest-runner.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const passthroughArgs = [];
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||
const arg = rawArgs[index];
|
||||
|
||||
if (arg === "--testPathPattern") {
|
||||
const value = rawArgs[index + 1];
|
||||
if (value) {
|
||||
passthroughArgs.push(value);
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--testPathPattern=")) {
|
||||
passthroughArgs.push(arg.slice("--testPathPattern=".length));
|
||||
continue;
|
||||
}
|
||||
|
||||
passthroughArgs.push(arg);
|
||||
}
|
||||
|
||||
const vitestBin = join(process.cwd(), "node_modules", ".bin", "vitest");
|
||||
const result = spawnSync(vitestBin, ["run", ...passthroughArgs], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -346,7 +346,9 @@ describe("AdminService", () => {
|
||||
data: { deactivatedAt: expect.any(Date) },
|
||||
})
|
||||
);
|
||||
expect(mockPrismaService.session.deleteMany).toHaveBeenCalledWith({ where: { userId: mockUserId } });
|
||||
expect(mockPrismaService.session.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if user does not exist", async () => {
|
||||
|
||||
@@ -63,6 +63,7 @@ import { ChatProxyModule } from "./chat-proxy/chat-proxy.module";
|
||||
import { MissionControlProxyModule } from "./mission-control-proxy/mission-control-proxy.module";
|
||||
import { OrchestratorModule } from "./orchestrator/orchestrator.module";
|
||||
import { QueueNotificationsModule } from "./queue-notifications/queue-notifications.module";
|
||||
import { GatekeeperModule } from "./gatekeeper/gatekeeper.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -146,6 +147,7 @@ import { QueueNotificationsModule } from "./queue-notifications/queue-notificati
|
||||
ChatProxyModule,
|
||||
MissionControlProxyModule,
|
||||
OrchestratorModule,
|
||||
GatekeeperModule,
|
||||
QueueNotificationsModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
|
||||
@@ -211,9 +211,7 @@ describe("AuthGuard", () => {
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Invalid user data in session"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("Invalid user data in session");
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is missing email", async () => {
|
||||
@@ -227,9 +225,7 @@ describe("AuthGuard", () => {
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Invalid user data in session"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("Invalid user data in session");
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is missing name", async () => {
|
||||
@@ -243,9 +239,7 @@ describe("AuthGuard", () => {
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Invalid user data in session"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("Invalid user data in session");
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is a string", async () => {
|
||||
@@ -259,9 +253,7 @@ describe("AuthGuard", () => {
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Invalid user data in session"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("Invalid user data in session");
|
||||
});
|
||||
|
||||
it("should reject when user is null (typeof null === 'object' causes TypeError on 'in' operator)", async () => {
|
||||
@@ -277,9 +269,7 @@ describe("AuthGuard", () => {
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(TypeError);
|
||||
await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(
|
||||
UnauthorizedException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.not.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -154,16 +154,14 @@ describe("CoordinatorIntegrationService", () => {
|
||||
// Mock transaction that passes through the callback
|
||||
mockPrismaService.$transaction.mockImplementation(async (callback) => {
|
||||
const mockTx = {
|
||||
$queryRaw: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{
|
||||
id: mockJob.id,
|
||||
status: mockJob.status,
|
||||
workspace_id: mockJob.workspaceId,
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
$queryRaw: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockJob.id,
|
||||
status: mockJob.status,
|
||||
workspace_id: mockJob.workspaceId,
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
runnerJob: {
|
||||
update: vi.fn().mockResolvedValue(updatedJob),
|
||||
},
|
||||
@@ -204,16 +202,14 @@ describe("CoordinatorIntegrationService", () => {
|
||||
// Mock transaction with completed job
|
||||
mockPrismaService.$transaction.mockImplementation(async (callback) => {
|
||||
const mockTx = {
|
||||
$queryRaw: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{
|
||||
id: mockJob.id,
|
||||
status: RunnerJobStatus.COMPLETED,
|
||||
workspace_id: mockJob.workspaceId,
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
$queryRaw: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockJob.id,
|
||||
status: RunnerJobStatus.COMPLETED,
|
||||
workspace_id: mockJob.workspaceId,
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
runnerJob: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
@@ -271,16 +267,14 @@ describe("CoordinatorIntegrationService", () => {
|
||||
// Mock transaction with running job
|
||||
mockPrismaService.$transaction.mockImplementation(async (callback) => {
|
||||
const mockTx = {
|
||||
$queryRaw: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{
|
||||
id: mockJob.id,
|
||||
status: RunnerJobStatus.RUNNING,
|
||||
workspace_id: mockJob.workspaceId,
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
$queryRaw: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockJob.id,
|
||||
status: RunnerJobStatus.RUNNING,
|
||||
workspace_id: mockJob.workspaceId,
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
runnerJob: {
|
||||
update: vi.fn().mockResolvedValue(completedJob),
|
||||
},
|
||||
@@ -315,16 +309,14 @@ describe("CoordinatorIntegrationService", () => {
|
||||
// Mock transaction with running job
|
||||
mockPrismaService.$transaction.mockImplementation(async (callback) => {
|
||||
const mockTx = {
|
||||
$queryRaw: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{
|
||||
id: mockJob.id,
|
||||
status: RunnerJobStatus.RUNNING,
|
||||
workspace_id: mockJob.workspaceId,
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
$queryRaw: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockJob.id,
|
||||
status: RunnerJobStatus.RUNNING,
|
||||
workspace_id: mockJob.workspaceId,
|
||||
version: 1,
|
||||
},
|
||||
]),
|
||||
runnerJob: {
|
||||
update: vi.fn().mockResolvedValue(failedJob),
|
||||
},
|
||||
|
||||
113
apps/api/src/gatekeeper/dto/gitea-pr-webhook.dto.ts
Normal file
113
apps/api/src/gatekeeper/dto/gitea-pr-webhook.dto.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
class GiteaLabelDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
}
|
||||
|
||||
class GiteaRepoDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(512)
|
||||
full_name!: string;
|
||||
}
|
||||
|
||||
class GiteaBranchRefDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
ref!: string;
|
||||
}
|
||||
|
||||
class GiteaHeadDto {
|
||||
@IsString()
|
||||
@MinLength(7)
|
||||
@MaxLength(128)
|
||||
sha!: string;
|
||||
}
|
||||
|
||||
class GiteaPullRequestDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
number!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
body?: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => GiteaBranchRefDto)
|
||||
base!: GiteaBranchRefDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => GiteaHeadDto)
|
||||
head!: GiteaHeadDto;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => GiteaLabelDto)
|
||||
labels!: GiteaLabelDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
html_url?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
url?: string;
|
||||
}
|
||||
|
||||
class GiteaSenderDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
login!: string;
|
||||
}
|
||||
|
||||
export class GiteaPrWebhookDto {
|
||||
@IsString()
|
||||
@IsIn(["pull_request"])
|
||||
@MaxLength(64)
|
||||
type!: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(["opened", "labeled", "synchronize"])
|
||||
@MaxLength(64)
|
||||
action!: "opened" | "labeled" | "synchronize";
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => GiteaRepoDto)
|
||||
repository!: GiteaRepoDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => GiteaPullRequestDto)
|
||||
pull_request!: GiteaPullRequestDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => GiteaLabelDto)
|
||||
label?: GiteaLabelDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => GiteaSenderDto)
|
||||
sender?: GiteaSenderDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
review_result?: Record<string, unknown>;
|
||||
}
|
||||
98
apps/api/src/gatekeeper/gatekeeper.controller.spec.ts
Normal file
98
apps/api/src/gatekeeper/gatekeeper.controller.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, type TestingModule } from "@nestjs/testing";
|
||||
import type { Request } from "express";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatekeeperController } from "./gatekeeper.controller";
|
||||
import { GatekeeperService } from "./gatekeeper.service";
|
||||
|
||||
describe("GatekeeperController", () => {
|
||||
let controller: GatekeeperController;
|
||||
|
||||
const gatekeeperService = {
|
||||
handlePrEvent: vi.fn(),
|
||||
};
|
||||
|
||||
const configService = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
const payload = {
|
||||
type: "pull_request",
|
||||
action: "labeled",
|
||||
label: { name: "auto-merge" },
|
||||
repository: { full_name: "mosaic/stack" },
|
||||
sender: { login: "jason" },
|
||||
pull_request: {
|
||||
number: 7,
|
||||
body: "ready",
|
||||
base: { ref: "main" },
|
||||
head: { sha: "abcdef1234567890" },
|
||||
labels: [{ name: "auto-merge" }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
configService.get.mockImplementation((key: string) => {
|
||||
if (key === "GITEA_WEBHOOK_SECRET") {
|
||||
return "secret";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
gatekeeperService.handlePrEvent.mockResolvedValue(undefined);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [GatekeeperController],
|
||||
providers: [
|
||||
{ provide: GatekeeperService, useValue: gatekeeperService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get(GatekeeperController);
|
||||
});
|
||||
|
||||
it("accepts a valid signature and schedules processing", async () => {
|
||||
const signature = createHmac("sha256", "secret").update(JSON.stringify(payload)).digest("hex");
|
||||
|
||||
await expect(
|
||||
controller.handleWebhook(
|
||||
{ rawBody: Buffer.from(JSON.stringify(payload)) } as Request,
|
||||
payload,
|
||||
signature
|
||||
)
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(gatekeeperService.handlePrEvent).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it("ignores invalid signatures", async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
|
||||
|
||||
await expect(controller.handleWebhook({} as Request, payload, "bad")).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
expect(gatekeeperService.handlePrEvent).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("invalid Gitea webhook signature")
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts requests without validation when no secret is configured", async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
|
||||
configService.get.mockReturnValue(undefined);
|
||||
|
||||
await expect(controller.handleWebhook({} as Request, payload, "")).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
expect(gatekeeperService.handlePrEvent).toHaveBeenCalledWith(payload);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GITEA_WEBHOOK_SECRET is not configured")
|
||||
);
|
||||
});
|
||||
});
|
||||
67
apps/api/src/gatekeeper/gatekeeper.controller.ts
Normal file
67
apps/api/src/gatekeeper/gatekeeper.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Body, Controller, Headers, Logger, Post, Req, type RawBodyRequest } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { Request } from "express";
|
||||
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
|
||||
import { GiteaPrWebhookDto } from "./dto/gitea-pr-webhook.dto";
|
||||
import { GatekeeperService } from "./gatekeeper.service";
|
||||
|
||||
@Controller("gatekeeper/webhook")
|
||||
export class GatekeeperController {
|
||||
private readonly logger = new Logger(GatekeeperController.name);
|
||||
|
||||
constructor(
|
||||
private readonly gatekeeperService: GatekeeperService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
@SkipCsrf()
|
||||
@Post("gitea")
|
||||
handleWebhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Body() body: GiteaPrWebhookDto,
|
||||
@Headers("x-gitea-signature") signature: string | undefined
|
||||
): Promise<{ ok: boolean }> {
|
||||
const secret = this.configService.get<string>("GITEA_WEBHOOK_SECRET");
|
||||
|
||||
if (secret && !this.isValidSignature(this.getRequestBody(req, body), signature, secret)) {
|
||||
this.logger.warn("Received invalid Gitea webhook signature");
|
||||
return Promise.resolve({ ok: true });
|
||||
}
|
||||
|
||||
if (!secret) {
|
||||
this.logger.warn("GITEA_WEBHOOK_SECRET is not configured; accepting Gitea webhook");
|
||||
}
|
||||
|
||||
void this.gatekeeperService.handlePrEvent(body).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to process Gitea PR webhook: ${message}`);
|
||||
});
|
||||
|
||||
return Promise.resolve({ ok: true });
|
||||
}
|
||||
|
||||
private getRequestBody(req: RawBodyRequest<Request>, body: GiteaPrWebhookDto): Buffer {
|
||||
if (Buffer.isBuffer(req.rawBody)) {
|
||||
return req.rawBody;
|
||||
}
|
||||
|
||||
return Buffer.from(JSON.stringify(body));
|
||||
}
|
||||
|
||||
private isValidSignature(body: Buffer, signature: string | undefined, secret: string): boolean {
|
||||
if (!signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = createHmac("sha256", secret).update(body).digest("hex");
|
||||
const actual = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
|
||||
if (actual.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(actual, expectedBuffer);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/gatekeeper/gatekeeper.module.ts
Normal file
12
apps/api/src/gatekeeper/gatekeeper.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { GatekeeperController } from "./gatekeeper.controller";
|
||||
import { GatekeeperService } from "./gatekeeper.service";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [GatekeeperController],
|
||||
providers: [GatekeeperService],
|
||||
exports: [GatekeeperService],
|
||||
})
|
||||
export class GatekeeperModule {}
|
||||
199
apps/api/src/gatekeeper/gatekeeper.service.spec.ts
Normal file
199
apps/api/src/gatekeeper/gatekeeper.service.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, type TestingModule } from "@nestjs/testing";
|
||||
import { describe, beforeEach, expect, it, vi } from "vitest";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { GiteaPrWebhookDto } from "./dto/gitea-pr-webhook.dto";
|
||||
import { GatekeeperService } from "./gatekeeper.service";
|
||||
|
||||
describe("GatekeeperService", () => {
|
||||
let service: GatekeeperService;
|
||||
|
||||
const prisma = {
|
||||
pendingMerge: {
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const config = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
const basePayload: GiteaPrWebhookDto = {
|
||||
type: "pull_request",
|
||||
action: "labeled",
|
||||
repository: { full_name: "mosaic/stack" },
|
||||
label: { name: "auto-merge" },
|
||||
sender: { login: "jason" },
|
||||
pull_request: {
|
||||
number: 42,
|
||||
body: "Implements Gatekeeper",
|
||||
base: { ref: "main" },
|
||||
head: { sha: "abcdef1234567890" },
|
||||
labels: [{ name: "auto-merge" }],
|
||||
url: "https://git.mosaicstack.dev/api/v1/repos/mosaic/stack/pulls/42",
|
||||
html_url: "https://git.mosaicstack.dev/mosaic/stack/pulls/42",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
config.get.mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case "GATEKEEPER_ENABLED":
|
||||
return "true";
|
||||
case "GITEA_API_TOKEN":
|
||||
return "token";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
GatekeeperService,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: ConfigService, useValue: config },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(GatekeeperService);
|
||||
});
|
||||
|
||||
it("moves labeled auto-merge PRs into awaiting_ci when review passes", async () => {
|
||||
prisma.pendingMerge.upsert.mockResolvedValue({ id: "merge-1" });
|
||||
prisma.pendingMerge.update.mockResolvedValue({});
|
||||
|
||||
await service.handlePrEvent(basePayload);
|
||||
|
||||
expect(prisma.pendingMerge.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
repo_prNumber_headSha: {
|
||||
repo: "mosaic/stack",
|
||||
prNumber: 42,
|
||||
headSha: "abcdef1234567890",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(prisma.pendingMerge.update).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ data: expect.objectContaining({ state: "reviewing" }) })
|
||||
);
|
||||
expect(prisma.pendingMerge.update).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
state: "awaiting_ci",
|
||||
reviewResult: { passed: true, issues: [] },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects review failures and records the reason", async () => {
|
||||
prisma.pendingMerge.upsert.mockResolvedValue({ id: "merge-2" });
|
||||
prisma.pendingMerge.findUnique.mockResolvedValue({
|
||||
id: "merge-2",
|
||||
repo: "mosaic/stack",
|
||||
prNumber: 42,
|
||||
headSha: "abcdef1234567890",
|
||||
});
|
||||
prisma.pendingMerge.update.mockResolvedValue({});
|
||||
const commentSpy = vi
|
||||
.spyOn(
|
||||
service as unknown as {
|
||||
postPullRequestComment: (repo: string, prNumber: number, body: string) => Promise<void>;
|
||||
},
|
||||
"postPullRequestComment"
|
||||
)
|
||||
.mockResolvedValue();
|
||||
|
||||
await service.handlePrEvent({
|
||||
...basePayload,
|
||||
pull_request: {
|
||||
...basePayload.pull_request,
|
||||
body: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.pendingMerge.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "merge-2" },
|
||||
data: {
|
||||
state: "rejected",
|
||||
reviewResult: {
|
||||
passed: false,
|
||||
issues: ["PR description must not be empty"],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(commentSpy).toHaveBeenCalledWith(
|
||||
"mosaic/stack",
|
||||
42,
|
||||
expect.stringContaining("PR description must not be empty")
|
||||
);
|
||||
});
|
||||
|
||||
it("attempts merge on green CI for awaiting_ci records", async () => {
|
||||
prisma.pendingMerge.findFirst.mockResolvedValue({
|
||||
id: "merge-3",
|
||||
repo: "mosaic/stack",
|
||||
prNumber: 42,
|
||||
headSha: "abcdef1234567890",
|
||||
state: "awaiting_ci",
|
||||
giteaMergeUrl: "https://git.mosaicstack.dev/api/v1/repos/mosaic/stack/pulls/42",
|
||||
});
|
||||
prisma.pendingMerge.update.mockResolvedValue({});
|
||||
const mergeSpy = vi.spyOn(service, "attemptMerge").mockResolvedValue();
|
||||
|
||||
await service.handleCiEvent("mosaic/stack", 42, "abcdef1234567890", "success");
|
||||
|
||||
expect(prisma.pendingMerge.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "merge-3" },
|
||||
data: expect.objectContaining({ ciStatus: "success" }),
|
||||
})
|
||||
);
|
||||
expect(mergeSpy).toHaveBeenCalledWith("merge-3");
|
||||
});
|
||||
|
||||
it("rejects failed CI results", async () => {
|
||||
prisma.pendingMerge.findFirst.mockResolvedValue({
|
||||
id: "merge-4",
|
||||
repo: "mosaic/stack",
|
||||
prNumber: 42,
|
||||
headSha: "abcdef1234567890",
|
||||
state: "awaiting_ci",
|
||||
});
|
||||
prisma.pendingMerge.update.mockResolvedValue({});
|
||||
const rejectSpy = vi.spyOn(service, "rejectMerge").mockResolvedValue();
|
||||
|
||||
await service.handleCiEvent("mosaic/stack", 42, "abcdef1234567890", "failure");
|
||||
|
||||
expect(rejectSpy).toHaveBeenCalledWith("merge-4", "CI reported failure");
|
||||
});
|
||||
|
||||
it("skips all work when Gatekeeper is disabled", async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
|
||||
config.get.mockImplementation((key: string) => {
|
||||
if (key === "GATEKEEPER_ENABLED") {
|
||||
return "false";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await service.handlePrEvent(basePayload);
|
||||
await service.handleCiEvent("mosaic/stack", 42, "abcdef1234567890", "success");
|
||||
|
||||
expect(prisma.pendingMerge.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.pendingMerge.findFirst).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Gatekeeper is disabled"));
|
||||
});
|
||||
});
|
||||
308
apps/api/src/gatekeeper/gatekeeper.service.ts
Normal file
308
apps/api/src/gatekeeper/gatekeeper.service.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
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 readonly giteaApiBaseUrl = "https://git.mosaicstack.dev/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;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { ApiKeyGuard } from "../common/guards/api-key.guard";
|
||||
import { GatekeeperModule } from "../gatekeeper/gatekeeper.module";
|
||||
import { QueueNotificationsController } from "./queue-notifications.controller";
|
||||
import { QueueNotificationsService } from "./queue-notifications.service";
|
||||
import { WoodpeckerWebhookController } from "./woodpecker-webhook.controller";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, AuthModule],
|
||||
imports: [ConfigModule, AuthModule, GatekeeperModule],
|
||||
controllers: [QueueNotificationsController, WoodpeckerWebhookController],
|
||||
providers: [QueueNotificationsService, ApiKeyGuard],
|
||||
exports: [QueueNotificationsService],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { Test, type TestingModule } from "@nestjs/testing";
|
||||
import type { Request } from "express";
|
||||
import { describe, beforeEach, expect, it, vi } from "vitest";
|
||||
import { GatekeeperService } from "../gatekeeper/gatekeeper.service";
|
||||
import { QueueNotificationsService } from "./queue-notifications.service";
|
||||
import {
|
||||
type WoodpeckerWebhookPayload,
|
||||
@@ -20,6 +21,9 @@ describe("WoodpeckerWebhookController", () => {
|
||||
const mockService = {
|
||||
notifyAgentCiResult: vi.fn(),
|
||||
};
|
||||
const mockGatekeeperService = {
|
||||
handleCiEvent: vi.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn(),
|
||||
@@ -39,6 +43,7 @@ describe("WoodpeckerWebhookController", () => {
|
||||
controllers: [WoodpeckerWebhookController],
|
||||
providers: [
|
||||
{ provide: QueueNotificationsService, useValue: mockService },
|
||||
{ provide: GatekeeperService, useValue: mockGatekeeperService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
@@ -52,6 +57,8 @@ describe("WoodpeckerWebhookController", () => {
|
||||
status: "success",
|
||||
buildUrl: "https://ci.example/build/123",
|
||||
repo: "mosaic/stack",
|
||||
prNumber: 42,
|
||||
headSha: "abcdef1234567890",
|
||||
};
|
||||
const signature = signPayload(payload, "test-secret");
|
||||
mockService.notifyAgentCiResult.mockResolvedValue({ notified: 2 });
|
||||
@@ -65,6 +72,12 @@ describe("WoodpeckerWebhookController", () => {
|
||||
).resolves.toEqual({ ok: true, notified: 2 });
|
||||
|
||||
expect(mockService.notifyAgentCiResult).toHaveBeenCalledWith(payload);
|
||||
expect(mockGatekeeperService.handleCiEvent).toHaveBeenCalledWith(
|
||||
"mosaic/stack",
|
||||
42,
|
||||
"abcdef1234567890",
|
||||
"success"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns ok without notifying when the signature is invalid", async () => {
|
||||
@@ -129,4 +142,22 @@ describe("WoodpeckerWebhookController", () => {
|
||||
)
|
||||
).resolves.toEqual({ ok: true, notified: 0 });
|
||||
});
|
||||
|
||||
it("does not call Gatekeeper when the PR metadata is missing", async () => {
|
||||
const payload: WoodpeckerWebhookPayload = {
|
||||
branch: "feat/ms24-ci-webhook",
|
||||
status: "success",
|
||||
buildUrl: "https://ci.example/build/555",
|
||||
repo: "mosaic/stack",
|
||||
};
|
||||
mockService.notifyAgentCiResult.mockResolvedValue({ notified: 1 });
|
||||
|
||||
await controller.handleWebhook(
|
||||
{ rawBody: Buffer.from(JSON.stringify(payload)) } as Request,
|
||||
payload,
|
||||
signPayload(payload, "test-secret")
|
||||
);
|
||||
|
||||
expect(mockGatekeeperService.handleCiEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { Request } from "express";
|
||||
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
|
||||
import { GatekeeperService } from "../gatekeeper/gatekeeper.service";
|
||||
import { QueueNotificationsService } from "./queue-notifications.service";
|
||||
|
||||
export interface WoodpeckerWebhookPayload {
|
||||
@@ -10,6 +11,8 @@ export interface WoodpeckerWebhookPayload {
|
||||
status: "success" | "failure";
|
||||
buildUrl: string;
|
||||
repo: string;
|
||||
prNumber?: number;
|
||||
headSha?: string;
|
||||
}
|
||||
|
||||
@Controller("webhooks")
|
||||
@@ -18,6 +21,7 @@ export class WoodpeckerWebhookController {
|
||||
|
||||
constructor(
|
||||
private readonly queueService: QueueNotificationsService,
|
||||
private readonly gatekeeperService: GatekeeperService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
@@ -33,6 +37,7 @@ export class WoodpeckerWebhookController {
|
||||
if (!secret) {
|
||||
this.logger.warn("WOODPECKER_WEBHOOK_SECRET is not configured; accepting Woodpecker webhook");
|
||||
const result = await this.queueService.notifyAgentCiResult(body);
|
||||
await this.forwardCiStatusToGatekeeper(body);
|
||||
return { ok: true, notified: result.notified };
|
||||
}
|
||||
|
||||
@@ -42,9 +47,21 @@ export class WoodpeckerWebhookController {
|
||||
}
|
||||
|
||||
const result = await this.queueService.notifyAgentCiResult(body);
|
||||
await this.forwardCiStatusToGatekeeper(body);
|
||||
return { ok: true, notified: result.notified };
|
||||
}
|
||||
|
||||
private async forwardCiStatusToGatekeeper(body: WoodpeckerWebhookPayload): Promise<void> {
|
||||
if (typeof body.prNumber === "number" && typeof body.headSha === "string") {
|
||||
await this.gatekeeperService.handleCiEvent(
|
||||
body.repo,
|
||||
body.prNumber,
|
||||
body.headSha,
|
||||
body.status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getRequestBody(req: RawBodyRequest<Request>, body: WoodpeckerWebhookPayload): Buffer {
|
||||
const rawBody = req.rawBody;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user