diff --git a/app/api/coding-agent/github/route.ts b/app/api/coding-agent/github/route.ts new file mode 100644 index 00000000..f1d615a7 --- /dev/null +++ b/app/api/coding-agent/github/route.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from "next/server"; +import { handleGitHubWebhook } from "@/lib/coding-agent/handleGitHubWebhook"; + +/** + * POST /api/coding-agent/github + * + * Webhook endpoint for GitHub PR comment feedback. + * Receives issue_comment events and triggers update-pr when the bot is mentioned. + * + * @param request - The incoming GitHub webhook request + */ +export async function POST(request: NextRequest) { + return handleGitHubWebhook(request); +} diff --git a/lib/coding-agent/__tests__/bot.test.ts b/lib/coding-agent/__tests__/bot.test.ts index ede2020d..185c2fa7 100644 --- a/lib/coding-agent/__tests__/bot.test.ts +++ b/lib/coding-agent/__tests__/bot.test.ts @@ -6,6 +6,12 @@ vi.mock("@chat-adapter/slack", () => ({ })), })); +vi.mock("@chat-adapter/github", () => ({ + createGitHubAdapter: vi.fn().mockReturnValue({ + name: "github", + }), +})); + vi.mock("@chat-adapter/state-ioredis", () => ({ createIoRedisState: vi.fn().mockReturnValue({ connect: vi.fn(), @@ -39,6 +45,7 @@ describe("createCodingAgentBot", () => { process.env.SLACK_BOT_TOKEN = "xoxb-test"; process.env.SLACK_SIGNING_SECRET = "test-signing-secret"; process.env.GITHUB_TOKEN = "ghp_test"; + process.env.GITHUB_WEBHOOK_SECRET = "test-webhook-secret"; process.env.REDIS_URL = "redis://localhost:6379"; process.env.CODING_AGENT_CALLBACK_SECRET = "test-callback-secret"; }); @@ -84,6 +91,32 @@ describe("createCodingAgentBot", () => { ); }); + it("creates a Chat instance with github adapter", async () => { + const { Chat } = await import("chat"); + const { createCodingAgentBot } = await import("../bot"); + + createCodingAgentBot(); + + const lastCall = vi.mocked(Chat).mock.calls.at(-1)!; + const config = lastCall[0]; + expect(config.adapters).toHaveProperty("github"); + }); + + it("creates GitHub adapter with correct config", async () => { + const { createGitHubAdapter } = await import("@chat-adapter/github"); + const { createCodingAgentBot } = await import("../bot"); + + createCodingAgentBot(); + + expect(createGitHubAdapter).toHaveBeenCalledWith( + expect.objectContaining({ + token: "ghp_test", + webhookSecret: "test-webhook-secret", + userName: "recoup-coding-agent", + }), + ); + }); + it("sets userName to Recoup Agent", async () => { const { Chat } = await import("chat"); const { createCodingAgentBot } = await import("../bot"); diff --git a/lib/coding-agent/__tests__/encodeGitHubThreadId.test.ts b/lib/coding-agent/__tests__/encodeGitHubThreadId.test.ts new file mode 100644 index 00000000..b69c7224 --- /dev/null +++ b/lib/coding-agent/__tests__/encodeGitHubThreadId.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { encodeGitHubThreadId } from "../encodeGitHubThreadId"; + +describe("encodeGitHubThreadId", () => { + it("encodes PR-level thread ID", () => { + expect( + encodeGitHubThreadId({ owner: "recoupable", repo: "tasks", prNumber: 68 }), + ).toBe("github:recoupable/tasks:68"); + }); + + it("encodes review comment thread ID", () => { + expect( + encodeGitHubThreadId({ + owner: "recoupable", + repo: "api", + prNumber: 266, + reviewCommentId: 2898626443, + }), + ).toBe("github:recoupable/api:266:rc:2898626443"); + }); +}); diff --git a/lib/coding-agent/__tests__/extractPRComment.test.ts b/lib/coding-agent/__tests__/extractPRComment.test.ts new file mode 100644 index 00000000..c20aad11 --- /dev/null +++ b/lib/coding-agent/__tests__/extractPRComment.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { extractPRComment } from "../extractPRComment"; + +const BASE_PAYLOAD = { + action: "created", + issue: { + number: 66, + pull_request: { url: "https://api.github.com/repos/recoupable/tasks/pulls/66" }, + }, + comment: { + id: 123, + body: "@recoup-coding-agent make the button blue", + user: { login: "sweetmantech" }, + }, + repository: { + full_name: "recoupable/tasks", + }, +}; + +describe("extractPRComment", () => { + it("returns null for unsupported events", () => { + expect(extractPRComment("push", BASE_PAYLOAD)).toBeNull(); + }); + + it("returns null when action is not created", () => { + expect(extractPRComment("issue_comment", { ...BASE_PAYLOAD, action: "deleted" })).toBeNull(); + }); + + it("returns null when bot is not mentioned", () => { + const payload = { + ...BASE_PAYLOAD, + comment: { body: "just a regular comment" }, + }; + expect(extractPRComment("issue_comment", payload)).toBeNull(); + }); + + it("returns null when issue has no pull_request", () => { + const payload = { + ...BASE_PAYLOAD, + issue: { number: 66 }, + }; + expect(extractPRComment("issue_comment", payload)).toBeNull(); + }); + + it("extracts from issue_comment with GitHubThreadId", () => { + const result = extractPRComment("issue_comment", BASE_PAYLOAD); + expect(result).toEqual({ + thread: { owner: "recoupable", repo: "tasks", prNumber: 66 }, + branch: "", + commentBody: "@recoup-coding-agent make the button blue", + }); + }); + + it("extracts from pull_request_review_comment with branch and reviewCommentId", () => { + const payload = { + action: "created", + pull_request: { + number: 266, + head: { ref: "feature/my-branch" }, + }, + comment: { + id: 2898626443, + body: "@recoup-coding-agent fix the typo", + }, + repository: { + full_name: "recoupable/api", + }, + }; + const result = extractPRComment("pull_request_review_comment", payload); + expect(result).toEqual({ + thread: { owner: "recoupable", repo: "api", prNumber: 266, reviewCommentId: 2898626443 }, + branch: "feature/my-branch", + commentBody: "@recoup-coding-agent fix the typo", + }); + }); + + it("returns null for pull_request_review_comment without pull_request", () => { + const payload = { + action: "created", + comment: { body: "@recoup-coding-agent test" }, + repository: { full_name: "recoupable/api" }, + }; + expect(extractPRComment("pull_request_review_comment", payload)).toBeNull(); + }); +}); diff --git a/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts b/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts new file mode 100644 index 00000000..5e059f4e --- /dev/null +++ b/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../verifyGitHubWebhook", () => ({ + verifyGitHubWebhook: vi.fn().mockResolvedValue(true), +})); + +const mockGetPRState = vi.fn(); +const mockSetPRState = vi.fn(); +vi.mock("../prState", () => ({ + getCodingAgentPRState: (...args: unknown[]) => mockGetPRState(...args), + setCodingAgentPRState: (...args: unknown[]) => mockSetPRState(...args), +})); + +const mockTriggerUpdatePR = vi.fn().mockResolvedValue({ id: "run_123" }); +vi.mock("@/lib/trigger/triggerUpdatePR", () => ({ + triggerUpdatePR: (...args: unknown[]) => mockTriggerUpdatePR(...args), +})); + +vi.mock("../postGitHubComment", () => ({ + postGitHubComment: vi.fn(), +})); + +global.fetch = vi.fn(); + +const { handleGitHubWebhook } = await import("../handleGitHubWebhook"); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_WEBHOOK_SECRET = "test-secret"; + process.env.GITHUB_TOKEN = "ghp_test"; +}); + +const BASE_PAYLOAD = { + action: "created", + issue: { + number: 66, + pull_request: { url: "https://api.github.com/repos/recoupable/tasks/pulls/66" }, + }, + comment: { + body: "@recoup-coding-agent make the button blue", + user: { login: "sweetmantech" }, + }, + repository: { + full_name: "recoupable/tasks", + }, +}; + +function makeRequest(body: unknown, event = "issue_comment", signature = "valid") { + return { + text: () => Promise.resolve(JSON.stringify(body)), + headers: new Headers({ + "x-github-event": event, + "x-hub-signature-256": signature, + }), + } as unknown as Request; +} + +describe("handleGitHubWebhook", () => { + it("returns 401 when signature is invalid", async () => { + const { verifyGitHubWebhook } = await import("../verifyGitHubWebhook"); + vi.mocked(verifyGitHubWebhook).mockResolvedValueOnce(false); + + const request = makeRequest(BASE_PAYLOAD); + const response = await handleGitHubWebhook(request); + expect(response.status).toBe(401); + }); + + it("returns 200 with ignored for non-issue_comment events", async () => { + const request = makeRequest(BASE_PAYLOAD, "push"); + const response = await handleGitHubWebhook(request); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("ignored"); + }); + + it("returns 200 with ignored when action is not created", async () => { + const request = makeRequest({ ...BASE_PAYLOAD, action: "deleted" }); + const response = await handleGitHubWebhook(request); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("ignored"); + }); + + it("returns 200 with ignored when issue has no pull_request", async () => { + const payload = { + ...BASE_PAYLOAD, + issue: { number: 66 }, + }; + const request = makeRequest(payload); + const response = await handleGitHubWebhook(request); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("ignored"); + }); + + it("returns 200 with ignored when bot is not mentioned", async () => { + const payload = { + ...BASE_PAYLOAD, + comment: { body: "just a regular comment", user: { login: "sweetmantech" } }, + }; + const request = makeRequest(payload); + const response = await handleGitHubWebhook(request); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("ignored"); + }); + + it("returns 200 with no_state when no shared PR state exists", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ head: { ref: "agent/fix-bug" } }), + } as Response); + mockGetPRState.mockResolvedValue(null); + + const request = makeRequest(BASE_PAYLOAD); + const response = await handleGitHubWebhook(request); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("no_state"); + }); + + it("returns 200 with busy when state is running", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ head: { ref: "agent/fix-bug" } }), + } as Response); + mockGetPRState.mockResolvedValue({ + status: "running", + branch: "agent/fix-bug", + repo: "recoupable/tasks", + }); + + const request = makeRequest(BASE_PAYLOAD); + const response = await handleGitHubWebhook(request); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("busy"); + }); + + it("triggers update-pr and posts GitHub comment when state is pr_created", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ head: { ref: "agent/fix-bug" } }), + } as Response); + mockGetPRState.mockResolvedValue({ + status: "pr_created", + snapshotId: "snap_abc", + branch: "agent/fix-bug", + repo: "recoupable/tasks", + prs: [{ repo: "recoupable/tasks", number: 66, url: "url", baseBranch: "main" }], + }); + vi.mocked(fetch).mockResolvedValueOnce({ ok: true } as Response); + + const request = makeRequest(BASE_PAYLOAD); + const response = await handleGitHubWebhook(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("update_triggered"); + + expect(mockTriggerUpdatePR).toHaveBeenCalledWith( + expect.objectContaining({ + feedback: "make the button blue", + snapshotId: "snap_abc", + branch: "agent/fix-bug", + repo: "recoupable/tasks", + }), + ); + + expect(mockSetPRState).toHaveBeenCalledWith( + "recoupable/tasks", + "agent/fix-bug", + expect.objectContaining({ status: "updating" }), + ); + }); + + it("handles pull_request_review_comment events without fetching PR details", async () => { + const reviewPayload = { + action: "created", + pull_request: { + number: 266, + head: { ref: "feature/my-branch" }, + }, + comment: { + body: "@recoup-coding-agent fix the typo", + user: { login: "sweetmantech" }, + }, + repository: { + full_name: "recoupable/api", + }, + }; + mockGetPRState.mockResolvedValue({ + status: "pr_created", + snapshotId: "snap_xyz", + branch: "feature/my-branch", + repo: "recoupable/api", + prs: [{ repo: "recoupable/api", number: 266, url: "url", baseBranch: "test" }], + }); + vi.mocked(fetch).mockResolvedValueOnce({ ok: true } as Response); + + const request = makeRequest(reviewPayload, "pull_request_review_comment"); + const response = await handleGitHubWebhook(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.status).toBe("update_triggered"); + + expect(mockTriggerUpdatePR).toHaveBeenCalledWith( + expect.objectContaining({ + feedback: "fix the typo", + snapshotId: "snap_xyz", + branch: "feature/my-branch", + repo: "recoupable/api", + }), + ); + + // Should NOT have fetched PR details — branch came from payload + expect(fetch).not.toHaveBeenCalledWith( + expect.stringContaining("/pulls/266"), + expect.anything(), + ); + }); + + it("restores PR state when triggerUpdatePR fails", async () => { + const reviewPayload = { + action: "created", + pull_request: { number: 99, head: { ref: "agent/fix-bug" } }, + comment: { + body: "@recoup-coding-agent fix it", + user: { login: "sweetmantech" }, + }, + repository: { full_name: "recoupable/tasks" }, + }; + const originalState = { + status: "pr_created" as const, + snapshotId: "snap_abc", + branch: "agent/fix-bug", + repo: "recoupable/tasks", + prs: [{ repo: "recoupable/tasks", number: 99, url: "url", baseBranch: "main" }], + }; + mockGetPRState.mockResolvedValueOnce({ ...originalState }); + mockTriggerUpdatePR.mockRejectedValueOnce(new Error("Trigger failed")); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const request = makeRequest(reviewPayload, "pull_request_review_comment"); + const response = await handleGitHubWebhook(request); + + expect(response.status).toBe(500); + const json = await response.json(); + expect(json.status).toBe("error"); + + // Should have set updating, then restored original state + expect(mockSetPRState).toHaveBeenCalledTimes(2); + expect(mockSetPRState).toHaveBeenLastCalledWith( + "recoupable/tasks", + "agent/fix-bug", + originalState, + ); + }); +}); diff --git a/lib/coding-agent/__tests__/postGitHubComment.test.ts b/lib/coding-agent/__tests__/postGitHubComment.test.ts new file mode 100644 index 00000000..b54c8e8d --- /dev/null +++ b/lib/coding-agent/__tests__/postGitHubComment.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +global.fetch = vi.fn(); + +const { postGitHubComment } = await import("../postGitHubComment"); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = "ghp_test"; +}); + +describe("postGitHubComment", () => { + it("posts a comment to the correct GitHub API endpoint", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: true } as Response); + + await postGitHubComment("recoupable/tasks", 68, "Hello from bot"); + + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/repos/recoupable/tasks/issues/68/comments", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ body: "Hello from bot" }), + }), + ); + }); + + it("includes auth headers", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: true } as Response); + + await postGitHubComment("recoupable/api", 10, "test"); + + const call = vi.mocked(fetch).mock.calls[0]; + const opts = call[1] as RequestInit; + const headers = opts.headers as Record; + expect(headers.Authorization).toBe("token ghp_test"); + expect(headers.Accept).toBe("application/vnd.github+json"); + }); + + it("logs error when fetch fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve("Forbidden"), + } as Response); + + await postGitHubComment("recoupable/tasks", 68, "test"); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to post GitHub comment"), + expect.anything(), + ); + consoleSpy.mockRestore(); + }); +}); diff --git a/lib/coding-agent/__tests__/validateEnv.test.ts b/lib/coding-agent/__tests__/validateEnv.test.ts index 37278c74..2123eb28 100644 --- a/lib/coding-agent/__tests__/validateEnv.test.ts +++ b/lib/coding-agent/__tests__/validateEnv.test.ts @@ -4,6 +4,7 @@ const REQUIRED_VARS = [ "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "GITHUB_TOKEN", + "GITHUB_WEBHOOK_SECRET", "REDIS_URL", "CODING_AGENT_CALLBACK_SECRET", ]; diff --git a/lib/coding-agent/__tests__/verifyGitHubWebhook.test.ts b/lib/coding-agent/__tests__/verifyGitHubWebhook.test.ts new file mode 100644 index 00000000..f17de3a7 --- /dev/null +++ b/lib/coding-agent/__tests__/verifyGitHubWebhook.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { verifyGitHubWebhook } from "../verifyGitHubWebhook"; + +describe("verifyGitHubWebhook", () => { + const secret = "test-webhook-secret"; + + it("returns true for a valid signature", async () => { + const body = '{"action":"created"}'; + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body)); + const hex = Array.from(new Uint8Array(sig)) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + const signature = `sha256=${hex}`; + + const result = await verifyGitHubWebhook(body, signature, secret); + expect(result).toBe(true); + }); + + it("returns false for an invalid signature", async () => { + const result = await verifyGitHubWebhook('{"action":"created"}', "sha256=bad", secret); + expect(result).toBe(false); + }); + + it("returns false for missing signature", async () => { + const result = await verifyGitHubWebhook('{"action":"created"}', "", secret); + expect(result).toBe(false); + }); +}); diff --git a/lib/coding-agent/bot.ts b/lib/coding-agent/bot.ts index 7ed49712..4cd2db31 100644 --- a/lib/coding-agent/bot.ts +++ b/lib/coding-agent/bot.ts @@ -1,5 +1,6 @@ import { Chat, ConsoleLogger } from "chat"; import { SlackAdapter } from "@chat-adapter/slack"; +import { createGitHubAdapter } from "@chat-adapter/github"; import { createIoRedisState } from "@chat-adapter/state-ioredis"; import redis from "@/lib/redis/connection"; import type { CodingAgentThreadState } from "./types"; @@ -32,9 +33,16 @@ export function createCodingAgentBot() { logger, }); - return new Chat<{ slack: SlackAdapter }, CodingAgentThreadState>({ + const github = createGitHubAdapter({ + token: process.env.GITHUB_TOKEN!, + webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!, + userName: process.env.GITHUB_BOT_USERNAME ?? "recoup-coding-agent", + logger, + }); + + return new Chat<{ slack: SlackAdapter; github: ReturnType }, CodingAgentThreadState>({ userName: "Recoup Agent", - adapters: { slack }, + adapters: { slack, github }, state, }); } diff --git a/lib/coding-agent/encodeGitHubThreadId.ts b/lib/coding-agent/encodeGitHubThreadId.ts new file mode 100644 index 00000000..1cfff2fe --- /dev/null +++ b/lib/coding-agent/encodeGitHubThreadId.ts @@ -0,0 +1,16 @@ +import type { GitHubThreadId } from "@chat-adapter/github"; + +/** + * Encodes a GitHubThreadId into the Chat SDK thread ID string format. + * Mirrors GitHubAdapter.encodeThreadId without needing an adapter instance. + * + * - PR-level: `github:{owner}/{repo}:{prNumber}` + * - Review comment: `github:{owner}/{repo}:{prNumber}:rc:{reviewCommentId}` + */ +export function encodeGitHubThreadId(thread: GitHubThreadId): string { + const { owner, repo, prNumber, reviewCommentId } = thread; + if (reviewCommentId) { + return `github:${owner}/${repo}:${prNumber}:rc:${reviewCommentId}`; + } + return `github:${owner}/${repo}:${prNumber}`; +} diff --git a/lib/coding-agent/extractPRComment.ts b/lib/coding-agent/extractPRComment.ts new file mode 100644 index 00000000..d5e7d567 --- /dev/null +++ b/lib/coding-agent/extractPRComment.ts @@ -0,0 +1,52 @@ +import type { GitHubThreadId } from "@chat-adapter/github"; + +const BOT_MENTION = "@recoup-coding-agent"; +const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment"]; + +export interface PRComment { + thread: GitHubThreadId; + branch: string; + commentBody: string; +} + +/** + * Extracts GitHub thread ID, branch, and comment body from a GitHub webhook payload. + * Returns null if the event is not actionable. + * + * @param event - The x-github-event header value + * @param payload - The parsed webhook payload + */ +export function extractPRComment( + event: string, + payload: Record, +): PRComment | null { + if (!SUPPORTED_EVENTS.includes(event)) return null; + + const action = payload.action as string | undefined; + if (action !== "created") return null; + + const comment = payload.comment as { body?: string; id?: number } | undefined; + const commentBody = comment?.body ?? ""; + if (!commentBody.includes(BOT_MENTION)) return null; + + const repository = payload.repository as { full_name: string }; + const [owner, repo] = repository.full_name.split("/"); + + if (event === "pull_request_review_comment") { + const pr = payload.pull_request as { number: number; head: { ref: string } } | undefined; + if (!pr) return null; + return { + thread: { owner, repo, prNumber: pr.number, reviewCommentId: comment?.id }, + branch: pr.head.ref, + commentBody, + }; + } + + const issue = payload.issue as { number: number; pull_request?: unknown } | undefined; + if (!issue?.pull_request) return null; + return { + thread: { owner, repo, prNumber: issue.number }, + branch: "", + commentBody, + }; +} diff --git a/lib/coding-agent/handleGitHubWebhook.ts b/lib/coding-agent/handleGitHubWebhook.ts new file mode 100644 index 00000000..fd6e5fc9 --- /dev/null +++ b/lib/coding-agent/handleGitHubWebhook.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { verifyGitHubWebhook } from "./verifyGitHubWebhook"; +import { encodeGitHubThreadId } from "./encodeGitHubThreadId"; +import { extractPRComment } from "./extractPRComment"; +import { getCodingAgentPRState, setCodingAgentPRState } from "./prState"; +import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; +import { postGitHubComment } from "./postGitHubComment"; + +const BOT_MENTION = "@recoup-coding-agent"; + +/** + * Handles incoming GitHub webhook requests for PR comment feedback. + * Supports both issue_comment and pull_request_review_comment events. + * Verifies signature, extracts PR context, and triggers update-pr when the bot is mentioned. + * + * @param request - The incoming webhook request + */ +export async function handleGitHubWebhook(request: Request): Promise { + const body = await request.text(); + const signature = request.headers.get("x-hub-signature-256") ?? ""; + const event = request.headers.get("x-github-event") ?? ""; + const secret = process.env.GITHUB_WEBHOOK_SECRET; + + if (!secret || !(await verifyGitHubWebhook(body, signature, secret))) { + return NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + + const payload = JSON.parse(body); + const extracted = extractPRComment(event, payload); + + if (!extracted) { + return NextResponse.json({ status: "ignored" }, { headers: getCorsHeaders() }); + } + + let { thread, branch, commentBody } = extracted; + const fullRepo = `${thread.owner}/${thread.repo}`; + const token = process.env.GITHUB_TOKEN; + + if (!branch) { + const prResponse = await fetch( + `https://api.github.com/repos/${fullRepo}/pulls/${thread.prNumber}`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github+json", + }, + }, + ); + + if (!prResponse.ok) { + return NextResponse.json( + { status: "error", error: "Failed to fetch PR details" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + const prData = await prResponse.json(); + branch = prData.head.ref; + } + + const prState = await getCodingAgentPRState(fullRepo, branch); + + if (!prState) { + return NextResponse.json({ status: "no_state" }, { headers: getCorsHeaders() }); + } + + if (prState.status === "running" || prState.status === "updating") { + return NextResponse.json({ status: "busy" }, { headers: getCorsHeaders() }); + } + + if (prState.status !== "pr_created" || !prState.snapshotId || !prState.prs?.length) { + return NextResponse.json({ status: "no_state" }, { headers: getCorsHeaders() }); + } + + const feedback = commentBody.replace(BOT_MENTION, "").trim(); + + await setCodingAgentPRState(fullRepo, branch, { + ...prState, + status: "updating", + }); + + const threadId = encodeGitHubThreadId(thread); + + try { + const handle = await triggerUpdatePR({ + feedback, + snapshotId: prState.snapshotId, + branch: prState.branch, + repo: prState.repo, + callbackThreadId: threadId, + }); + + await postGitHubComment( + fullRepo, + thread.prNumber, + `Got your feedback. Updating the PRs...\n\n[View Task](https://chat.recoupable.com/tasks/${handle.id})`, + ); + + return NextResponse.json({ status: "update_triggered" }, { headers: getCorsHeaders() }); + } catch (error) { + await setCodingAgentPRState(fullRepo, branch, prState); + console.error("Failed to trigger update-pr:", error); + return NextResponse.json( + { status: "error", error: "Failed to trigger update" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/coding-agent/postGitHubComment.ts b/lib/coding-agent/postGitHubComment.ts new file mode 100644 index 00000000..007c36a1 --- /dev/null +++ b/lib/coding-agent/postGitHubComment.ts @@ -0,0 +1,31 @@ +/** + * Posts a comment on a GitHub issue/PR. + * + * @param repo - Full repo name (owner/repo) + * @param prNumber - The PR/issue number + * @param body - The comment body (markdown) + */ +export async function postGitHubComment( + repo: string, + prNumber: number, + body: string, +): Promise { + const token = process.env.GITHUB_TOKEN; + + const response = await fetch( + `https://api.github.com/repos/${repo}/issues/${prNumber}/comments`, + { + method: "POST", + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github+json", + }, + body: JSON.stringify({ body }), + }, + ); + + if (!response.ok) { + const text = await response.text(); + console.error(`Failed to post GitHub comment on ${repo}#${prNumber}:`, text); + } +} diff --git a/lib/coding-agent/validateEnv.ts b/lib/coding-agent/validateEnv.ts index 51e0a36c..425a32d7 100644 --- a/lib/coding-agent/validateEnv.ts +++ b/lib/coding-agent/validateEnv.ts @@ -2,6 +2,7 @@ const REQUIRED_ENV_VARS = [ "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "GITHUB_TOKEN", + "GITHUB_WEBHOOK_SECRET", "REDIS_URL", "CODING_AGENT_CALLBACK_SECRET", ] as const; diff --git a/lib/coding-agent/verifyGitHubWebhook.ts b/lib/coding-agent/verifyGitHubWebhook.ts new file mode 100644 index 00000000..330ebfd4 --- /dev/null +++ b/lib/coding-agent/verifyGitHubWebhook.ts @@ -0,0 +1,35 @@ +import { timingSafeEqual } from "crypto"; + +/** + * Verifies a GitHub webhook signature using HMAC SHA-256. + * Uses constant-time comparison to prevent timing attacks. + * + * @param body - Raw request body string + * @param signature - The x-hub-signature-256 header value + * @param secret - The webhook secret + */ +export async function verifyGitHubWebhook( + body: string, + signature: string, + secret: string, +): Promise { + if (!signature) return false; + + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body)); + const hex = Array.from(new Uint8Array(sig)) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + const expected = `sha256=${hex}`; + + if (signature.length !== expected.length) return false; + + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); +}