From bc9f5f3fd00ab61a4f1ec293e57b01334c8ef1eb Mon Sep 17 00:00:00 2001 From: paperclip Date: Fri, 24 Apr 2026 19:49:07 +0000 Subject: [PATCH] feat: auto-close issues when linked PR merges via GitHub webhook Add server-side GitHub webhook handler at POST /api/github/webhooks that listens for pull_request.closed events with merged=true. When a PR merges, the handler extracts issue identifiers (e.g. SEC-52, OLL-89) from the PR title and body, and also checks issue_work_products for linked PRs by URL. Any matched issues in in_review status are automatically transitioned to done with an auto-close comment. Closes SEC-52 Co-Authored-By: Paperclip --- .env.example | 3 + .../__tests__/github-webhook-routes.test.ts | 259 ++++++++++++++++++ server/src/app.ts | 2 + server/src/routes/github-webhooks.ts | 182 ++++++++++++ server/src/routes/index.ts | 1 + 5 files changed, 447 insertions(+) create mode 100644 server/src/__tests__/github-webhook-routes.test.ts create mode 100644 server/src/routes/github-webhooks.ts diff --git a/.env.example b/.env.example index 84e20d96058..1c8f18b40a1 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,8 @@ PORT=3100 SERVE_UI=false BETTER_AUTH_SECRET=paperclip-dev-secret +# GitHub webhook secret for PR-merge auto-close (POST /api/github/webhooks) +# GITHUB_WEBHOOK_SECRET=your-webhook-secret-here + # Discord webhook for daily merge digest (scripts/discord-daily-digest.sh) # DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... diff --git a/server/src/__tests__/github-webhook-routes.test.ts b/server/src/__tests__/github-webhook-routes.test.ts new file mode 100644 index 00000000000..179190c2a2b --- /dev/null +++ b/server/src/__tests__/github-webhook-routes.test.ts @@ -0,0 +1,259 @@ +import { createHmac } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIssueService = vi.hoisted(() => ({ + update: vi.fn(async () => ({})), + addComment: vi.fn(async () => ({ id: "comment-1", body: "" })), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +const mockDbSelect = vi.hoisted(() => { + const chain = { + from: vi.fn(() => chain), + where: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + then: vi.fn(async () => []), + }; + return vi.fn(() => chain); +}); + +const mockDb = vi.hoisted(() => ({ + select: mockDbSelect, +})); + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + issueService: () => mockIssueService, + logActivity: mockLogActivity, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); +} + +const WEBHOOK_SECRET = "test-webhook-secret"; + +function sign(body: string): string { + return "sha256=" + createHmac("sha256", WEBHOOK_SECRET).update(body).digest("hex"); +} + +function makePrPayload(overrides?: Partial<{ + action: string; + merged: boolean; + title: string; + body: string | null; + number: number; +}>) { + return { + action: overrides?.action ?? "closed", + pull_request: { + merged: overrides?.merged ?? true, + number: overrides?.number ?? 42, + title: overrides?.title ?? "feat: implement FOO-123 feature", + body: overrides?.body ?? "Closes FOO-123\nAlso refs BAR-456", + html_url: "https://github.com/org/repo/pull/42", + base: { repo: { full_name: "org/repo" } }, + }, + }; +} + +async function createApp() { + const { githubWebhookRoutes } = await import("../routes/github-webhooks.js"); + const app = express(); + app.use(express.json({ + verify: (req, _res, buf) => { + (req as any).rawBody = buf; + }, + })); + app.use("/api", githubWebhookRoutes(mockDb as any)); + return app; +} + +describe("github webhook routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + registerModuleMocks(); + process.env.GITHUB_WEBHOOK_SECRET = WEBHOOK_SECRET; + }); + + describe("signature verification", () => { + it("rejects requests without signature", async () => { + const app = await createApp(); + await request(app) + .post("/api/github/webhooks") + .send(makePrPayload()) + .expect(401); + }); + + it("rejects requests with invalid signature", async () => { + const app = await createApp(); + await request(app) + .post("/api/github/webhooks") + .set("x-hub-signature-256", "sha256=invalid") + .set("x-github-event", "pull_request") + .send(makePrPayload()) + .expect(401); + }); + + it("returns 503 when GITHUB_WEBHOOK_SECRET is not set", async () => { + delete process.env.GITHUB_WEBHOOK_SECRET; + const app = await createApp(); + const body = JSON.stringify(makePrPayload()); + await request(app) + .post("/api/github/webhooks") + .set("x-hub-signature-256", sign(body)) + .set("x-github-event", "pull_request") + .set("content-type", "application/json") + .send(body) + .expect(503); + }); + }); + + describe("event filtering", () => { + it("ignores non-pull_request events", async () => { + const app = await createApp(); + const body = JSON.stringify({ action: "completed" }); + await request(app) + .post("/api/github/webhooks") + .set("x-hub-signature-256", sign(body)) + .set("x-github-event", "check_run") + .set("content-type", "application/json") + .send(body) + .expect(200) + .expect((res) => { + expect(res.body.ignored).toBe(true); + }); + }); + + it("ignores closed but not merged PRs", async () => { + const app = await createApp(); + const payload = makePrPayload({ merged: false }); + const body = JSON.stringify(payload); + await request(app) + .post("/api/github/webhooks") + .set("x-hub-signature-256", sign(body)) + .set("x-github-event", "pull_request") + .set("content-type", "application/json") + .send(body) + .expect(200) + .expect((res) => { + expect(res.body.ignored).toBe(true); + expect(res.body.reason).toBe("PR not merged"); + }); + }); + + it("ignores opened PRs", async () => { + const app = await createApp(); + const payload = makePrPayload({ action: "opened" }); + const body = JSON.stringify(payload); + await request(app) + .post("/api/github/webhooks") + .set("x-hub-signature-256", sign(body)) + .set("x-github-event", "pull_request") + .set("content-type", "application/json") + .send(body) + .expect(200) + .expect((res) => { + expect(res.body.ignored).toBe(true); + }); + }); + }); + + describe("issue identifier extraction", () => { + it("extracts identifiers from PR title and body", async () => { + const app = await createApp(); + const selectChain = { + from: vi.fn(() => selectChain), + where: vi.fn(() => [ + { + id: "issue-1", + identifier: "FOO-123", + status: "in_review", + companyId: "company-1", + assigneeAgentId: "agent-1", + }, + ]), + innerJoin: vi.fn(() => selectChain), + then: vi.fn(async () => []), + }; + mockDbSelect.mockReturnValue(selectChain as any); + // First select call returns matched issues, second call (work products) returns empty + selectChain.where + .mockResolvedValueOnce([ + { + id: "issue-1", + identifier: "FOO-123", + status: "in_review", + companyId: "company-1", + assigneeAgentId: "agent-1", + }, + ]) + .mockResolvedValueOnce([]); + + const payload = makePrPayload({ title: "feat: FOO-123 and BAR-456" }); + const body = JSON.stringify(payload); + await request(app) + .post("/api/github/webhooks") + .set("x-hub-signature-256", sign(body)) + .set("x-github-event", "pull_request") + .set("content-type", "application/json") + .send(body) + .expect(200) + .expect((res) => { + expect(res.body.processed).toBe(true); + expect(res.body.closedIssueCount).toBe(1); + }); + + expect(mockIssueService.update).toHaveBeenCalledWith("issue-1", { status: "done" }); + expect(mockIssueService.addComment).toHaveBeenCalledWith( + "issue-1", + expect.stringContaining("Auto-closed: PR [#42]"), + expect.any(Object), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.auto_closed_pr_merged", + entityId: "issue-1", + }), + ); + }); + }); + + describe("valid merged PR with no matching issues", () => { + it("returns 200 with zero closed issues", async () => { + const app = await createApp(); + const selectChain = { + from: vi.fn(() => selectChain), + where: vi.fn(async () => []), + innerJoin: vi.fn(() => selectChain), + }; + mockDbSelect.mockReturnValue(selectChain as any); + + const payload = makePrPayload({ title: "chore: update deps", body: null }); + const body = JSON.stringify(payload); + await request(app) + .post("/api/github/webhooks") + .set("x-hub-signature-256", sign(body)) + .set("x-github-event", "pull_request") + .set("content-type", "application/json") + .send(body) + .expect(200) + .expect((res) => { + expect(res.body.processed).toBe(true); + expect(res.body.closedIssueCount).toBe(0); + }); + + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 6b27357301d..ebdb4841a9b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -38,6 +38,7 @@ import { llmRoutes } from "./routes/llms.js"; import { authRoutes } from "./routes/auth.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; +import { githubWebhookRoutes } from "./routes/github-webhooks.js"; import { pluginRoutes } from "./routes/plugins.js"; import { adapterRoutes } from "./routes/adapters.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; @@ -271,6 +272,7 @@ export async function createApp( }, }, ); + api.use(githubWebhookRoutes(db)); api.use( pluginRoutes( db, diff --git a/server/src/routes/github-webhooks.ts b/server/src/routes/github-webhooks.ts new file mode 100644 index 00000000000..231e09a0b31 --- /dev/null +++ b/server/src/routes/github-webhooks.ts @@ -0,0 +1,182 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { Router, type Request, type Response } from "express"; +import { and, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { issues, issueWorkProducts } from "@paperclipai/db"; +import { issueService, logActivity } from "../services/index.js"; +import { logger } from "../middleware/logger.js"; + +const ISSUE_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]+-\d+)\b/g; + +function extractIssueIdentifiers(text: string): string[] { + const matches = text.match(ISSUE_IDENTIFIER_RE); + return matches ? [...new Set(matches)] : []; +} + +function verifySignature(secret: string, payload: Buffer, signature: string): boolean { + const expected = "sha256=" + createHmac("sha256", secret).update(payload).digest("hex"); + const expectedBuf = Buffer.from(expected); + const sigBuf = Buffer.from(signature); + if (expectedBuf.length !== sigBuf.length) return false; + return timingSafeEqual(expectedBuf, sigBuf); +} + +interface PullRequestPayload { + action: string; + pull_request: { + merged: boolean; + number: number; + title: string; + body: string | null; + html_url: string; + base: { repo: { full_name: string } }; + }; +} + +export function githubWebhookRoutes(db: Db) { + const router = Router(); + + router.post("/github/webhooks", async (req: Request, res: Response) => { + const secret = process.env.GITHUB_WEBHOOK_SECRET; + if (!secret) { + logger.warn("GITHUB_WEBHOOK_SECRET not configured — rejecting webhook"); + res.status(503).json({ error: "GitHub webhooks not configured" }); + return; + } + + const signature = req.headers["x-hub-signature-256"]; + if (typeof signature !== "string") { + res.status(401).json({ error: "Missing signature" }); + return; + } + + const rawBody = (req as unknown as { rawBody: Buffer }).rawBody; + if (!rawBody || !verifySignature(secret, rawBody, signature)) { + res.status(401).json({ error: "Invalid signature" }); + return; + } + + const event = req.headers["x-github-event"]; + if (event !== "pull_request") { + res.status(200).json({ ignored: true, reason: "not a pull_request event" }); + return; + } + + const payload = req.body as PullRequestPayload; + if (payload.action !== "closed" || !payload.pull_request?.merged) { + res.status(200).json({ ignored: true, reason: "PR not merged" }); + return; + } + + const pr = payload.pull_request; + const prUrl = pr.html_url; + const prNumber = pr.number; + const repoFullName = pr.base.repo.full_name; + + const textToSearch = `${pr.title}\n${pr.body ?? ""}`; + const identifiers = extractIssueIdentifiers(textToSearch); + + const closedIssueIds: string[] = []; + const svc = issueService(db); + + if (identifiers.length > 0) { + const matchedIssues = await db + .select({ + id: issues.id, + identifier: issues.identifier, + status: issues.status, + companyId: issues.companyId, + assigneeAgentId: issues.assigneeAgentId, + }) + .from(issues) + .where( + and( + inArray(issues.identifier, identifiers), + eq(issues.status, "in_review"), + ), + ); + + for (const issue of matchedIssues) { + await svc.update(issue.id, { + status: "done", + }); + await svc.addComment( + issue.id, + `Auto-closed: PR [#${prNumber}](${prUrl}) merged in \`${repoFullName}\``, + { agentId: undefined, userId: undefined, runId: null }, + ); + await logActivity(db, { + companyId: issue.companyId, + actorType: "system", + actorId: "system", + action: "issue.auto_closed_pr_merged", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + prUrl, + prNumber, + repoFullName, + }, + }); + closedIssueIds.push(issue.id); + logger.info({ issueId: issue.id, identifier: issue.identifier, prUrl }, "Auto-closed issue on PR merge"); + } + } + + const workProductIssues = await db + .select({ + issueId: issueWorkProducts.issueId, + id: issues.id, + identifier: issues.identifier, + status: issues.status, + companyId: issues.companyId, + }) + .from(issueWorkProducts) + .innerJoin(issues, eq(issueWorkProducts.issueId, issues.id)) + .where( + and( + eq(issueWorkProducts.type, "pull_request"), + eq(issueWorkProducts.url, prUrl), + eq(issues.status, "in_review"), + ), + ); + + for (const issue of workProductIssues) { + if (closedIssueIds.includes(issue.id)) continue; + + await svc.update(issue.id, { + status: "done", + }); + await svc.addComment( + issue.id, + `Auto-closed: PR [#${prNumber}](${prUrl}) merged in \`${repoFullName}\``, + { agentId: undefined, userId: undefined, runId: null }, + ); + await logActivity(db, { + companyId: issue.companyId, + actorType: "system", + actorId: "system", + action: "issue.auto_closed_pr_merged", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + prUrl, + prNumber, + repoFullName, + }, + }); + closedIssueIds.push(issue.id); + logger.info({ issueId: issue.id, identifier: issue.identifier, prUrl }, "Auto-closed issue on PR merge (work product link)"); + } + + res.status(200).json({ + processed: true, + closedIssueCount: closedIssueIds.length, + closedIssueIds, + }); + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 983562e265a..0bc74bf509c 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -1,3 +1,4 @@ +export { githubWebhookRoutes } from "./github-webhooks.js"; export { healthRoutes } from "./health.js"; export { companyRoutes } from "./companies.js"; export { companySkillRoutes } from "./company-skills.js";