diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index b0448b53c..6f5799838 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -36,6 +36,17 @@ projects: # plugin: linear # teamId: "your-team-id" + # SCM webhook acceleration (optional) + # scm: + # plugin: github + # webhook: + # path: /api/webhooks/github + # secretEnvVar: GITHUB_WEBHOOK_SECRET + # signatureHeader: x-hub-signature-256 + # eventHeader: x-github-event + # deliveryHeader: x-github-delivery + # maxBodyBytes: 1048576 + # Files to symlink into workspaces # symlinks: [.env, .claude] @@ -45,11 +56,7 @@ projects: # Agent-specific config # agentConfig: - # permissions: permissionless # modes: permissionless | default | auto-edit | suggest - # # - permissionless: no interactive permission prompts - # # - default: agent defaults - # # - auto-edit: auto-approve edits where supported - # # - suggest: conservative/untrusted-approval mode where supported + # permissions: skip # --dangerously-skip-permissions # model: opus # Inline rules included in every agent prompt for this project @@ -67,10 +74,6 @@ projects: # OpenCode issue session strategy (only for agent: opencode) # opencodeIssueSessionStrategy: reuse # reuse | delete | ignore - # OpenCode orchestrator session strategy (only for agent: opencode) - # Controls how the orchestrator's OpenCode session is managed - # orchestratorSessionStrategy: reuse # reuse | delete | ignore - # Per-project reaction overrides # reactions: # approved-and-green: diff --git a/packages/core/package.json b/packages/core/package.json index 3c6bc66b9..c43340771 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,6 +18,10 @@ "./utils": { "types": "./dist/utils.d.ts", "import": "./dist/utils.js" + }, + "./scm-webhook-utils": { + "types": "./dist/scm-webhook-utils.d.ts", + "import": "./dist/scm-webhook-utils.js" } }, "files": [ diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 97310b972..a8a1a033b 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -274,6 +274,64 @@ describe("Config Validation - Session Prefix Regex", () => { }); }); +describe("Config Validation - SCM webhook contract", () => { + it("accepts a project scm webhook block and defaults enabled=true", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + scm: { + plugin: "github", + webhook: { + path: "/api/webhooks/github", + secretEnvVar: "GITHUB_WEBHOOK_SECRET", + eventHeader: "x-github-event", + deliveryHeader: "x-github-delivery", + signatureHeader: "x-hub-signature-256", + maxBodyBytes: 1048576, + }, + }, + }, + }, + }); + + expect(config.projects["proj1"]?.scm).toEqual({ + plugin: "github", + webhook: { + enabled: true, + path: "/api/webhooks/github", + secretEnvVar: "GITHUB_WEBHOOK_SECRET", + eventHeader: "x-github-event", + deliveryHeader: "x-github-delivery", + signatureHeader: "x-hub-signature-256", + maxBodyBytes: 1048576, + }, + }); + }); + + it("rejects non-positive scm webhook maxBodyBytes", () => { + expect(() => + validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + scm: { + plugin: "github", + webhook: { + maxBodyBytes: 0, + }, + }, + }, + }, + }), + ).toThrow(); + }); +}); + describe("Config Schema Validation", () => { it("requires projects field", () => { const config = { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 743d66fe2..dea31124c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -70,6 +70,17 @@ const TrackerConfigSchema = z const SCMConfigSchema = z .object({ plugin: z.string(), + webhook: z + .object({ + enabled: z.boolean().default(true), + path: z.string().optional(), + secretEnvVar: z.string().optional(), + signatureHeader: z.string().optional(), + eventHeader: z.string().optional(), + deliveryHeader: z.string().optional(), + maxBodyBytes: z.number().int().positive().optional(), + }) + .optional(), }) .passthrough(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 481bd35d2..3efd48cf7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -95,6 +95,12 @@ export { normalizeRetryConfig, readLastJsonlEntry, } from "./utils.js"; +export { + getWebhookHeader, + parseWebhookJsonObject, + parseWebhookTimestamp, + parseWebhookBranchRef, +} from "./scm-webhook-utils.js"; export { asValidOpenCodeSessionId } from "./opencode-session-id.js"; export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; diff --git a/packages/core/src/scm-webhook-utils.ts b/packages/core/src/scm-webhook-utils.ts new file mode 100644 index 000000000..6b78d448a --- /dev/null +++ b/packages/core/src/scm-webhook-utils.ts @@ -0,0 +1,35 @@ +import type { SCMWebhookRequest } from "./types.js"; + +export function getWebhookHeader( + headers: SCMWebhookRequest["headers"], + name: string, +): string | undefined { + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== target) continue; + if (Array.isArray(value)) return value[0]; + return value; + } + return undefined; +} + +export function parseWebhookJsonObject(body: string): Record { + const parsed: unknown = JSON.parse(body); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Webhook payload must be a JSON object"); + } + return parsed as Record; +} + +export function parseWebhookTimestamp(value: unknown): Date | undefined { + if (typeof value !== "string") return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +export function parseWebhookBranchRef(ref: unknown): string | undefined { + if (typeof ref !== "string" || ref.length === 0) return undefined; + if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length); + if (ref.startsWith("refs/")) return undefined; + return ref; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d083f2c88..9d2bf2746 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -518,6 +518,16 @@ export interface CreateIssueInput { export interface SCM { readonly name: string; + verifyWebhook?( + request: SCMWebhookRequest, + project: ProjectConfig, + ): Promise; + + parseWebhook?( + request: SCMWebhookRequest, + project: ProjectConfig, + ): Promise; + // --- PR Lifecycle --- /** Detect if a session has an open PR (by branch name) */ @@ -601,6 +611,42 @@ export const PR_STATE = { export type MergeMethod = "merge" | "squash" | "rebase"; +export interface SCMWebhookRequest { + method: string; + headers: Record; + body: string; + rawBody?: Uint8Array; + path?: string; + query?: Record; +} + +export interface SCMWebhookVerificationResult { + ok: boolean; + reason?: string; + deliveryId?: string; + eventType?: string; +} + +export type SCMWebhookEventKind = "pull_request" | "ci" | "review" | "comment" | "push" | "unknown"; + +export interface SCMWebhookEvent { + provider: string; + kind: SCMWebhookEventKind; + action: string; + rawEventType: string; + deliveryId?: string; + projectId?: string; + repository?: { + owner: string; + name: string; + }; + prNumber?: number; + branch?: string; + sha?: string; + timestamp?: Date; + data: Record; +} + // --- CI Types --- export interface CICheck { @@ -951,9 +997,20 @@ export interface TrackerConfig { export interface SCMConfig { plugin: string; + webhook?: SCMWebhookConfig; [key: string]: unknown; } +export interface SCMWebhookConfig { + enabled?: boolean; + path?: string; + secretEnvVar?: string; + signatureHeader?: string; + eventHeader?: string; + deliveryHeader?: string; + maxBodyBytes?: number; +} + export interface NotifierConfig { plugin: string; [key: string]: unknown; diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 6222943ac..f57860cd0 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -5,11 +5,15 @@ */ import { execFile } from "node:child_process"; +import { createHmac, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; import { CI_STATUS, type PluginModule, type SCM, + type SCMWebhookEvent, + type SCMWebhookRequest, + type SCMWebhookVerificationResult, type Session, type ProjectConfig, type PRInfo, @@ -23,6 +27,12 @@ import { type AutomatedComment, type MergeReadiness, } from "@composio/ao-core"; +import { + getWebhookHeader, + parseWebhookBranchRef, + parseWebhookJsonObject, + parseWebhookTimestamp, +} from "@composio/ao-core/scm-webhook-utils"; const execFileAsync = promisify(execFile); @@ -46,20 +56,16 @@ const BOT_AUTHORS = new Set([ type ExecCommand = "gh" | "git"; -async function execCli( - command: ExecCommand, - args: string[], - options?: { cwd?: string }, -): Promise { +async function execCli(bin: ExecCommand, args: string[], cwd?: string): Promise { try { - const { stdout } = await execFileAsync(command, args, { - cwd: options?.cwd, + const { stdout } = await execFileAsync(bin, args, { + ...(cwd ? { cwd } : {}), maxBuffer: 10 * 1024 * 1024, timeout: 30_000, }); return stdout.trim(); } catch (err) { - throw new Error(`${command} ${args.slice(0, 3).join(" ")} failed: ${(err as Error).message}`, { + throw new Error(`${bin} ${args.slice(0, 3).join(" ")} failed: ${(err as Error).message}`, { cause: err, }); } @@ -70,21 +76,44 @@ async function gh(args: string[]): Promise { } async function ghInDir(args: string[], cwd: string): Promise { - return execCli("gh", args, { cwd }); + return execCli("gh", args, cwd); } async function git(args: string[], cwd: string): Promise { - return execCli("git", args, { cwd }); + return execCli("git", args, cwd); } -function repoFlag(pr: PRInfo): string { - return `${pr.owner}/${pr.repo}`; +function parseProjectRepo(projectRepo: string): [string, string] { + const parts = projectRepo.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid repo format "${projectRepo}", expected "owner/repo"`); + } + return [parts[0], parts[1]]; } -function parseDate(val: string | undefined | null): Date { - if (!val) return new Date(0); - const d = new Date(val); - return isNaN(d.getTime()) ? new Date(0) : d; +function prInfoFromView( + data: { + number: number; + url: string; + title: string; + headRefName: string; + baseRefName: string; + isDraft: boolean; + }, + projectRepo: string, +): PRInfo { + const [owner, repo] = parseProjectRepo(projectRepo); + + return { + number: data.number, + url: data.url, + title: data.title, + owner, + repo, + branch: data.headRefName, + baseBranch: data.baseRefName, + isDraft: data.isDraft, + }; } function isUnsupportedPrChecksJsonError(err: unknown): boolean { @@ -192,39 +221,226 @@ async function getCIChecksFromStatusRollup(pr: PRInfo): Promise { .filter((check): check is CICheck => check !== null); } -function parseProjectRepo(projectRepo: string): [string, string] { - const parts = projectRepo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error(`Invalid repo format "${projectRepo}", expected "owner/repo"`); - } - return [parts[0], parts[1]]; +function getGitHubWebhookConfig(project: ProjectConfig) { + const webhook = project.scm?.webhook; + return { + enabled: webhook?.enabled !== false, + path: webhook?.path ?? "/api/webhooks/github", + secretEnvVar: webhook?.secretEnvVar, + signatureHeader: webhook?.signatureHeader ?? "x-hub-signature-256", + eventHeader: webhook?.eventHeader ?? "x-github-event", + deliveryHeader: webhook?.deliveryHeader ?? "x-github-delivery", + maxBodyBytes: webhook?.maxBodyBytes, + }; } -function prInfoFromView( - data: { - number: number; - url: string; - title: string; - headRefName: string; - baseRefName: string; - isDraft: boolean; - }, - projectRepo: string, -): PRInfo { - const [owner, repo] = parseProjectRepo(projectRepo); +function verifyGitHubSignature( + body: string | Uint8Array, + secret: string, + signatureHeader: string, +): boolean { + if (!signatureHeader.startsWith("sha256=")) return false; + const expected = createHmac("sha256", secret).update(body).digest("hex"); + const provided = signatureHeader.slice("sha256=".length); + const expectedBuffer = Buffer.from(expected, "hex"); + const providedBuffer = Buffer.from(provided, "hex"); + if (expectedBuffer.length !== providedBuffer.length) return false; + return timingSafeEqual(expectedBuffer, providedBuffer); +} + +function parseGitHubRepository(payload: Record) { + const repository = payload["repository"]; + if (!repository || typeof repository !== "object") return undefined; + const repo = repository as Record; + const ownerValue = repo["owner"]; + const ownerLogin = + ownerValue && typeof ownerValue === "object" + ? (ownerValue as Record)["login"] + : undefined; + const owner = typeof ownerLogin === "string" ? ownerLogin : undefined; + const name = typeof repo["name"] === "string" ? repo["name"] : undefined; + if (!owner || !name) return undefined; + return { owner, name }; +} + +function parseGitHubWebhookEvent( + request: SCMWebhookRequest, + payload: Record, + config: ReturnType, +): SCMWebhookEvent | null { + const rawEventType = getWebhookHeader(request.headers, config.eventHeader); + if (!rawEventType) return null; + + const deliveryId = getWebhookHeader(request.headers, config.deliveryHeader); + const repository = parseGitHubRepository(payload); + const action = typeof payload["action"] === "string" ? payload["action"] : rawEventType; + + if (rawEventType === "pull_request") { + const pullRequest = payload["pull_request"]; + if (!pullRequest || typeof pullRequest !== "object") return null; + const pr = pullRequest as Record; + const head = pr["head"] as Record | undefined; + return { + provider: "github", + kind: "pull_request", + action, + rawEventType, + deliveryId, + repository, + prNumber: + typeof payload["number"] === "number" + ? (payload["number"] as number) + : typeof pr["number"] === "number" + ? (pr["number"] as number) + : undefined, + branch: typeof head?.["ref"] === "string" ? head["ref"] : undefined, + sha: typeof head?.["sha"] === "string" ? head["sha"] : undefined, + timestamp: parseWebhookTimestamp(pr["updated_at"]), + data: payload, + }; + } + + if (rawEventType === "pull_request_review" || rawEventType === "pull_request_review_comment") { + const pullRequest = payload["pull_request"]; + if (!pullRequest || typeof pullRequest !== "object") return null; + const pr = pullRequest as Record; + const head = pr["head"] as Record | undefined; + return { + provider: "github", + kind: rawEventType === "pull_request_review" ? "review" : "comment", + action, + rawEventType, + deliveryId, + repository, + prNumber: + typeof payload["number"] === "number" + ? (payload["number"] as number) + : typeof pr["number"] === "number" + ? (pr["number"] as number) + : undefined, + branch: typeof head?.["ref"] === "string" ? head["ref"] : undefined, + sha: typeof head?.["sha"] === "string" ? head["sha"] : undefined, + timestamp: + rawEventType === "pull_request_review" + ? parseWebhookTimestamp( + (payload["review"] as Record | undefined)?.["submitted_at"], + ) + : parseWebhookTimestamp( + (payload["comment"] as Record | undefined)?.["updated_at"] ?? + (payload["comment"] as Record | undefined)?.["created_at"], + ), + data: payload, + }; + } + + if (rawEventType === "issue_comment") { + const issue = payload["issue"]; + if (!issue || typeof issue !== "object") return null; + const issueRecord = issue as Record; + if (!("pull_request" in issueRecord)) return null; + return { + provider: "github", + kind: "comment", + action, + rawEventType, + deliveryId, + repository, + prNumber: typeof issueRecord["number"] === "number" ? issueRecord["number"] : undefined, + timestamp: parseWebhookTimestamp( + (payload["comment"] as Record | undefined)?.["updated_at"] ?? + (payload["comment"] as Record | undefined)?.["created_at"], + ), + data: payload, + }; + } + + if (rawEventType === "check_run" || rawEventType === "check_suite") { + const check = payload[rawEventType] as Record | undefined; + const pullRequests = Array.isArray(check?.["pull_requests"]) + ? (check?.["pull_requests"] as Array>) + : []; + const firstPR = pullRequests[0]; + return { + provider: "github", + kind: "ci", + action, + rawEventType, + deliveryId, + repository, + prNumber: typeof firstPR?.["number"] === "number" ? firstPR["number"] : undefined, + branch: + typeof check?.["head_branch"] === "string" + ? (check["head_branch"] as string) + : typeof (check?.["check_suite"] as Record | undefined)?.[ + "head_branch" + ] === "string" + ? ((check?.["check_suite"] as Record)["head_branch"] as string) + : undefined, + sha: typeof check?.["head_sha"] === "string" ? (check["head_sha"] as string) : undefined, + timestamp: parseWebhookTimestamp(check?.["updated_at"]), + data: payload, + }; + } + + if (rawEventType === "status") { + const branches = Array.isArray(payload["branches"]) + ? (payload["branches"] as Array>) + : []; + return { + provider: "github", + kind: "ci", + action: typeof payload["state"] === "string" ? (payload["state"] as string) : action, + rawEventType, + deliveryId, + repository, + branch: parseWebhookBranchRef(branches[0]?.["name"] ?? payload["ref"]), + sha: typeof payload["sha"] === "string" ? (payload["sha"] as string) : undefined, + timestamp: parseWebhookTimestamp(payload["updated_at"]), + data: payload, + }; + } + + if (rawEventType === "push") { + const headCommit = + payload["head_commit"] && typeof payload["head_commit"] === "object" + ? (payload["head_commit"] as Record) + : undefined; + return { + provider: "github", + kind: "push", + action, + rawEventType, + deliveryId, + repository, + branch: parseWebhookBranchRef(payload["ref"]), + sha: typeof payload["after"] === "string" ? (payload["after"] as string) : undefined, + timestamp: parseWebhookTimestamp(headCommit?.["timestamp"] ?? payload["updated_at"]), + data: payload, + }; + } return { - number: data.number, - url: data.url, - title: data.title, - owner, - repo, - branch: data.headRefName, - baseBranch: data.baseRefName, - isDraft: data.isDraft, + provider: "github", + kind: "unknown", + action, + rawEventType, + deliveryId, + repository, + timestamp: parseWebhookTimestamp(payload["updated_at"]), + data: payload, }; } +function repoFlag(pr: PRInfo): string { + return `${pr.owner}/${pr.repo}`; +} + +function parseDate(val: string | undefined | null): Date { + if (!val) return new Date(0); + const d = new Date(val); + return isNaN(d.getTime()) ? new Date(0) : d; +} + // --------------------------------------------------------------------------- // SCM implementation // --------------------------------------------------------------------------- @@ -233,10 +449,69 @@ function createGitHubSCM(): SCM { return { name: "github", + async verifyWebhook( + request: SCMWebhookRequest, + project: ProjectConfig, + ): Promise { + const config = getGitHubWebhookConfig(project); + if (!config.enabled) { + return { ok: false, reason: "Webhook is disabled for this project" }; + } + if (request.method.toUpperCase() !== "POST") { + return { ok: false, reason: "Webhook requests must use POST" }; + } + if ( + config.maxBodyBytes !== undefined && + Buffer.byteLength(request.body, "utf8") > config.maxBodyBytes + ) { + return { ok: false, reason: "Webhook payload exceeds configured maxBodyBytes" }; + } + + const eventType = getWebhookHeader(request.headers, config.eventHeader); + if (!eventType) { + return { ok: false, reason: `Missing ${config.eventHeader} header` }; + } + + const deliveryId = getWebhookHeader(request.headers, config.deliveryHeader); + const secretName = config.secretEnvVar; + if (!secretName) { + return { ok: true, deliveryId, eventType }; + } + + const secret = process.env[secretName]; + if (!secret) { + return { ok: false, reason: `Webhook secret env var ${secretName} is not configured` }; + } + + const signature = getWebhookHeader(request.headers, config.signatureHeader); + if (!signature) { + return { ok: false, reason: `Missing ${config.signatureHeader} header` }; + } + + if (!verifyGitHubSignature(request.rawBody ?? request.body, secret, signature)) { + return { + ok: false, + reason: "Webhook signature verification failed", + deliveryId, + eventType, + }; + } + + return { ok: true, deliveryId, eventType }; + }, + + async parseWebhook( + request: SCMWebhookRequest, + project: ProjectConfig, + ): Promise { + const config = getGitHubWebhookConfig(project); + const payload = parseWebhookJsonObject(request.body); + return parseGitHubWebhookEvent(request, payload, config); + }, + async detectPR(session: Session, project: ProjectConfig): Promise { if (!session.branch) return null; parseProjectRepo(project.repo); - try { const raw = await gh([ "pr", @@ -385,13 +660,12 @@ function createGitHubSCM(): SCM { return checks.map((c) => { const state = c.state?.toUpperCase(); - const status = mapRawCheckStateToStatus(state); return { name: c.name, - status, + status: mapRawCheckStateToStatus(state), url: c.link || undefined, - conclusion: state || undefined, // Store original state for debugging + conclusion: state || undefined, startedAt: c.startedAt ? new Date(c.startedAt) : undefined, completedAt: c.completedAt ? new Date(c.completedAt) : undefined, }; @@ -400,9 +674,6 @@ function createGitHubSCM(): SCM { if (isUnsupportedPrChecksJsonError(err)) { return getCIChecksFromStatusRollup(pr); } - // Propagate so callers (getCISummary) can decide how to handle. - // Do NOT silently return [] — that causes a fail-open where CI - // appears healthy when we simply failed to fetch check status. throw new Error("Failed to fetch CI checks", { cause: err }); } }, @@ -583,8 +854,6 @@ function createGitHubSCM(): SCM { }; }); } catch (err) { - // Propagate so callers (maybeDispatchReviewBacklog) can distinguish - // "no comments" from "failed to check" and avoid clearing metadata. throw new Error("Failed to fetch pending comments", { cause: err }); } }, diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index 6b2523d54..c910cffed 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -15,7 +15,7 @@ vi.mock("node:child_process", () => { }); import { create, manifest } from "../src/index.js"; -import type { PRInfo, Session, ProjectConfig } from "@composio/ao-core"; +import type { PRInfo, SCMWebhookRequest, Session, ProjectConfig } from "@composio/ao-core"; // --------------------------------------------------------------------------- // Fixtures @@ -67,6 +67,26 @@ function mockGhError(msg = "Command failed") { ghMock.mockRejectedValueOnce(new Error(msg)); } +function makeWebhookRequest(overrides: Partial = {}): SCMWebhookRequest { + return { + method: "POST", + headers: { + "x-github-event": "pull_request", + "x-github-delivery": "delivery-1", + }, + body: JSON.stringify({ + action: "opened", + repository: { owner: { login: "acme" }, name: "repo" }, + pull_request: { + number: 42, + updated_at: "2026-03-10T12:00:00Z", + head: { ref: "feat/my-feature", sha: "abc123" }, + }, + }), + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -77,6 +97,7 @@ describe("scm-github plugin", () => { beforeEach(() => { vi.clearAllMocks(); scm = create(); + delete process.env["GITHUB_WEBHOOK_SECRET"]; }); // ---- manifest ---------------------------------------------------------- @@ -97,6 +118,271 @@ describe("scm-github plugin", () => { }); }); + describe("verifyWebhook", () => { + it("accepts unsigned webhooks when no secret is configured", async () => { + await expect(scm.verifyWebhook?.(makeWebhookRequest(), project)).resolves.toEqual({ + ok: true, + deliveryId: "delivery-1", + eventType: "pull_request", + }); + }); + + it("verifies a valid HMAC signature", async () => { + process.env["GITHUB_WEBHOOK_SECRET"] = "topsecret"; + const body = makeWebhookRequest().body; + const signature = await import("node:crypto").then( + ({ createHmac }) => + `sha256=${createHmac("sha256", "topsecret").update(body).digest("hex")}`, + ); + + const result = await scm.verifyWebhook?.( + makeWebhookRequest({ + headers: { + "x-github-event": "pull_request", + "x-github-delivery": "delivery-1", + "x-hub-signature-256": signature, + }, + }), + { + ...project, + scm: { plugin: "github", webhook: { secretEnvVar: "GITHUB_WEBHOOK_SECRET" } }, + }, + ); + + expect(result?.ok).toBe(true); + }); + + it("rejects an invalid HMAC signature", async () => { + process.env["GITHUB_WEBHOOK_SECRET"] = "topsecret"; + + const result = await scm.verifyWebhook?.( + makeWebhookRequest({ + headers: { + "x-github-event": "pull_request", + "x-github-delivery": "delivery-1", + "x-hub-signature-256": "sha256=deadbeef", + }, + }), + { + ...project, + scm: { plugin: "github", webhook: { secretEnvVar: "GITHUB_WEBHOOK_SECRET" } }, + }, + ); + + expect(result).toEqual( + expect.objectContaining({ ok: false, reason: "Webhook signature verification failed" }), + ); + }); + }); + + describe("parseWebhook", () => { + it("parses pull_request events", async () => { + const event = await scm.parseWebhook?.(makeWebhookRequest(), project); + expect(event).toEqual( + expect.objectContaining({ + provider: "github", + kind: "pull_request", + action: "opened", + prNumber: 42, + branch: "feat/my-feature", + sha: "abc123", + }), + ); + }); + + it("omits repository when owner.login is not a string", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + body: JSON.stringify({ + action: "opened", + repository: { owner: { login: 123 }, name: "repo" }, + pull_request: { + number: 42, + updated_at: "2026-03-10T12:00:00Z", + head: { ref: "feat/my-feature", sha: "abc123" }, + }, + }), + }), + project, + ); + + expect(event).toEqual( + expect.objectContaining({ kind: "pull_request", repository: undefined }), + ); + }); + + it("parses issue_comment events on pull requests as comment events", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { "x-github-event": "issue_comment" }, + body: JSON.stringify({ + action: "created", + repository: { owner: { login: "acme" }, name: "repo" }, + issue: { number: 42, pull_request: { url: "https://api.github.com/..." } }, + comment: { updated_at: "2026-03-10T12:00:00Z" }, + }), + }), + project, + ); + + expect(event).toEqual( + expect.objectContaining({ provider: "github", kind: "comment", prNumber: 42 }), + ); + }); + + it("falls back to comment.created_at for issue_comment timestamps", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { "x-github-event": "issue_comment" }, + body: JSON.stringify({ + action: "created", + repository: { owner: { login: "acme" }, name: "repo" }, + issue: { number: 42, pull_request: { url: "https://api.github.com/..." } }, + comment: { created_at: "2026-03-10T12:00:00Z" }, + }), + }), + project, + ); + + expect(event).toEqual( + expect.objectContaining({ provider: "github", kind: "comment", prNumber: 42 }), + ); + expect(event?.timestamp?.toISOString()).toBe("2026-03-10T12:00:00.000Z"); + }); + + it("parses pull_request_review_comment timestamp from comment payload", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { "x-github-event": "pull_request_review_comment" }, + body: JSON.stringify({ + action: "created", + repository: { owner: { login: "acme" }, name: "repo" }, + number: 42, + pull_request: { + number: 42, + head: { ref: "feat/my-feature", sha: "abc123" }, + }, + comment: { created_at: "2026-03-10T12:00:00Z" }, + }), + }), + project, + ); + + expect(event).toEqual( + expect.objectContaining({ + provider: "github", + kind: "comment", + prNumber: 42, + }), + ); + expect(event?.timestamp?.toISOString()).toBe("2026-03-10T12:00:00.000Z"); + }); + + it("parses status events with branch info", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { "x-github-event": "status" }, + body: JSON.stringify({ + state: "failure", + repository: { owner: { login: "acme" }, name: "repo" }, + sha: "def456", + branches: [{ name: "feat/my-feature" }], + updated_at: "2026-03-10T12:00:00Z", + }), + }), + project, + ); + + expect(event).toEqual( + expect.objectContaining({ + provider: "github", + kind: "ci", + action: "failure", + branch: "feat/my-feature", + sha: "def456", + }), + ); + }); + + it("parses check_run events using check_suite.head_branch", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { "x-github-event": "check_run" }, + body: JSON.stringify({ + action: "completed", + repository: { owner: { login: "acme" }, name: "repo" }, + check_run: { + head_sha: "def456", + updated_at: "2026-03-10T12:00:00Z", + pull_requests: [{ number: 42 }], + check_suite: { head_branch: "feat/my-feature" }, + }, + }), + }), + project, + ); + + expect(event).toEqual( + expect.objectContaining({ + provider: "github", + kind: "ci", + branch: "feat/my-feature", + sha: "def456", + prNumber: 42, + }), + ); + }); + + it("parses push events with branch and sha", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { "x-github-event": "push" }, + body: JSON.stringify({ + ref: "refs/heads/feat/my-feature", + after: "abcde12345", + repository: { owner: { login: "acme" }, name: "repo" }, + head_commit: { timestamp: "2026-03-10T12:01:00Z" }, + }), + }), + project, + ); + + expect(event).toEqual( + expect.objectContaining({ + provider: "github", + kind: "push", + branch: "feat/my-feature", + sha: "abcde12345", + }), + ); + expect(event?.timestamp?.toISOString()).toBe("2026-03-10T12:01:00.000Z"); + }); + + it("does not set branch for tag push refs", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { "x-github-event": "push" }, + body: JSON.stringify({ + ref: "refs/tags/v1.0.0", + after: "abcde12345", + repository: { owner: { login: "acme" }, name: "repo" }, + head_commit: { timestamp: "2026-03-10T12:01:00Z" }, + }), + }), + project, + ); + + expect(event).toEqual( + expect.objectContaining({ + provider: "github", + kind: "push", + branch: undefined, + sha: "abcde12345", + }), + ); + }); + }); + // ---- detectPR ---------------------------------------------------------- describe("detectPR", () => { @@ -148,7 +434,6 @@ describe("scm-github plugin", () => { await expect(scm.detectPR(makeSession(), badProject)).rejects.toThrow("Invalid repo format"); }); - it("rejects repo strings with extra path segments", async () => { const badProject = { ...project, repo: "acme/repo/extra" }; await expect(scm.detectPR(makeSession(), badProject)).rejects.toThrow("Invalid repo format"); @@ -168,6 +453,39 @@ describe("scm-github plugin", () => { const result = await scm.detectPR(makeSession(), project); expect(result?.isDraft).toBe(true); }); + + it("resolves PR by reference", async () => { + mockGh({ + number: 42, + url: "https://github.com/acme/repo/pull/42", + title: "feat: add feature", + headRefName: "feat/my-feature", + baseRefName: "main", + isDraft: false, + }); + + const result = await scm.resolvePR?.("42", project); + expect(result).toEqual(pr); + }); + + it("assigns PR to current user", async () => { + ghMock.mockResolvedValueOnce({ stdout: "" }); + await scm.assignPRToCurrentUser?.(pr); + expect(ghMock).toHaveBeenCalledWith( + "gh", + ["pr", "edit", "42", "--repo", "acme/repo", "--add-assignee", "@me"], + expect.any(Object), + ); + }); + + it("checks out PR when workspace is clean and branch differs", async () => { + ghMock.mockResolvedValueOnce({ stdout: "main\n" }); + ghMock.mockResolvedValueOnce({ stdout: "" }); + ghMock.mockResolvedValueOnce({ stdout: "" }); + + const changed = await scm.checkoutPR?.(pr, "/tmp/repo"); + expect(changed).toBe(true); + }); }); // ---- getPRState -------------------------------------------------------- @@ -194,80 +512,6 @@ describe("scm-github plugin", () => { }); }); - // ---- resolvePR --------------------------------------------------------- - - describe("resolvePR", () => { - it("resolves a PR number into canonical PR info", async () => { - mockGh({ - number: 42, - url: "https://github.com/acme/repo/pull/42", - title: "feat: add feature", - headRefName: "feat/my-feature", - baseRefName: "main", - isDraft: false, - }); - - await expect(scm.resolvePR?.("42", project)).resolves.toEqual(pr); - }); - }); - - // ---- assignPRToCurrentUser -------------------------------------------- - - describe("assignPRToCurrentUser", () => { - it("assigns the PR to the authenticated user", async () => { - ghMock.mockResolvedValueOnce({ stdout: "" }); - - await scm.assignPRToCurrentUser?.(pr); - - expect(ghMock).toHaveBeenCalledWith( - "gh", - ["pr", "edit", "42", "--repo", "acme/repo", "--add-assignee", "@me"], - expect.any(Object), - ); - }); - }); - - // ---- checkoutPR -------------------------------------------------------- - - describe("checkoutPR", () => { - it("returns false when already on the PR branch", async () => { - ghMock.mockResolvedValueOnce({ stdout: "feat/my-feature\n" }); - - await expect(scm.checkoutPR?.(pr, "/tmp/repo")).resolves.toBe(false); - - expect(ghMock).toHaveBeenCalledTimes(1); - expect(ghMock).toHaveBeenCalledWith( - "git", - ["branch", "--show-current"], - expect.objectContaining({ cwd: "/tmp/repo" }), - ); - }); - - it("throws when switching branches would discard local changes", async () => { - ghMock.mockResolvedValueOnce({ stdout: "main\n" }); - ghMock.mockResolvedValueOnce({ stdout: " M src/index.ts\n" }); - - await expect(scm.checkoutPR?.(pr, "/tmp/repo")).rejects.toThrow( - 'Workspace has uncommitted changes; cannot switch to PR branch "feat/my-feature" safely', - ); - }); - - it("checks out the PR when the workspace is clean", async () => { - ghMock.mockResolvedValueOnce({ stdout: "main\n" }); - ghMock.mockResolvedValueOnce({ stdout: "" }); - ghMock.mockResolvedValueOnce({ stdout: "" }); - - await expect(scm.checkoutPR?.(pr, "/tmp/repo")).resolves.toBe(true); - - expect(ghMock).toHaveBeenNthCalledWith( - 3, - "gh", - ["pr", "checkout", "42", "--repo", "acme/repo"], - expect.objectContaining({ cwd: "/tmp/repo" }), - ); - }); - }); - // ---- mergePR ----------------------------------------------------------- describe("mergePR", () => { @@ -337,12 +581,10 @@ describe("scm-github plugin", () => { { name: "queued", state: "QUEUED", link: "", startedAt: "", completedAt: "" }, { name: "cancelled", state: "CANCELLED", link: "", startedAt: "", completedAt: "" }, { name: "action_req", state: "ACTION_REQUIRED", link: "", startedAt: "", completedAt: "" }, - { name: "stale", state: "STALE", link: "", startedAt: "", completedAt: "" }, - { name: "unknown", state: "SOME_NEW_STATE", link: "", startedAt: "", completedAt: "" }, ]); const checks = await scm.getCIChecks(pr); - expect(checks).toHaveLength(12); + expect(checks).toHaveLength(10); expect(checks[0].status).toBe("passed"); expect(checks[0].url).toBe("https://ci/1"); expect(checks[1].status).toBe("failed"); @@ -354,8 +596,6 @@ describe("scm-github plugin", () => { expect(checks[7].status).toBe("pending"); expect(checks[8].status).toBe("failed"); // CANCELLED expect(checks[9].status).toBe("failed"); // ACTION_REQUIRED - expect(checks[10].status).toBe("skipped"); - expect(checks[11].status).toBe("skipped"); }); it("throws on error (fail-closed)", async () => { @@ -376,34 +616,23 @@ describe("scm-github plugin", () => { expect(checks[0].completedAt).toBeUndefined(); }); - it("falls back to statusCheckRollup when gh pr checks --json is unsupported", async () => { - mockGhError('gh pr checks failed: Unknown JSON field "state"'); + it("falls back to statusCheckRollup when pr checks json is unsupported", async () => { + mockGhError("gh pr checks failed: unknown json field 'state'"); mockGh({ statusCheckRollup: [ { - __typename: "CheckRun", - name: "Test", - status: "COMPLETED", - conclusion: "SUCCESS", - detailsUrl: "https://ci/test", + name: "build", + state: "SUCCESS", + detailsUrl: "https://ci/1", startedAt: "2025-01-01T00:00:00Z", - completedAt: "2025-01-01T00:05:00Z", - }, - { - __typename: "StatusContext", - context: "Lint", - state: "PENDING", - targetUrl: "https://ci/lint", - createdAt: "2025-01-01T00:01:00Z", + completedAt: "2025-01-01T00:01:00Z", }, ], }); const checks = await scm.getCIChecks(pr); - expect(checks).toHaveLength(2); - expect(checks[0]).toMatchObject({ name: "Test", status: "passed", url: "https://ci/test" }); - expect(checks[1]).toMatchObject({ name: "Lint", status: "pending", url: "https://ci/lint" }); - expect(ghMock).toHaveBeenCalledTimes(2); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ name: "build", status: "passed" }); }); }); @@ -451,32 +680,6 @@ describe("scm-github plugin", () => { ]); expect(await scm.getCISummary(pr)).toBe("none"); }); - - it('returns "none" for stale/unknown check states instead of false failing', async () => { - mockGh([ - { name: "a", state: "STALE" }, - { name: "b", state: "SOME_NEW_STATE" }, - ]); - expect(await scm.getCISummary(pr)).toBe("none"); - }); - - it('uses fallback checks source before reporting "failing"', async () => { - mockGhError('gh pr checks failed: Unknown JSON field "state"'); - mockGh({ - statusCheckRollup: [ - { - __typename: "CheckRun", - name: "Build", - status: "COMPLETED", - conclusion: "SUCCESS", - detailsUrl: "https://ci/build", - }, - ], - }); - - expect(await scm.getCISummary(pr)).toBe("passing"); - expect(ghMock).toHaveBeenCalledTimes(2); - }); }); // ---- getReviews -------------------------------------------------------- @@ -676,7 +879,7 @@ describe("scm-github plugin", () => { expect(comments[0].author).toBe("alice"); }); - it("throws on error so callers can distinguish failure from empty", async () => { + it("throws on error", async () => { mockGhError("API rate limit"); await expect(scm.getPendingComments(pr)).rejects.toThrow("Failed to fetch pending comments"); }); @@ -794,7 +997,7 @@ describe("scm-github plugin", () => { expect(comments).toEqual([]); }); - it("throws on error so callers can distinguish failure from empty", async () => { + it("throws on error", async () => { mockGhError("network failure"); await expect(scm.getAutomatedComments(pr)).rejects.toThrow( "Failed to fetch automated comments", diff --git a/packages/plugins/scm-gitlab/src/index.ts b/packages/plugins/scm-gitlab/src/index.ts index 7c516fe64..b48b554e9 100644 --- a/packages/plugins/scm-gitlab/src/index.ts +++ b/packages/plugins/scm-gitlab/src/index.ts @@ -4,10 +4,14 @@ * Uses the `glab` CLI for GitLab API interactions. */ +import { createHash, timingSafeEqual } from "node:crypto"; import { CI_STATUS, type PluginModule, type SCM, + type SCMWebhookEvent, + type SCMWebhookRequest, + type SCMWebhookVerificationResult, type Session, type ProjectConfig, type PRInfo, @@ -21,6 +25,12 @@ import { type AutomatedComment, type MergeReadiness, } from "@composio/ao-core"; +import { + getWebhookHeader, + parseWebhookBranchRef, + parseWebhookJsonObject, + parseWebhookTimestamp, +} from "@composio/ao-core/scm-webhook-utils"; import { glab, parseJSON, stripHost } from "./glab-utils.js"; @@ -36,7 +46,9 @@ const BOT_AUTHORS = new Set([ ]); function isBot(username: string): boolean { - return BOT_AUTHORS.has(username) || /^project_\d+_bot/.test(username) || username.endsWith("[bot]"); + return ( + BOT_AUTHORS.has(username) || /^project_\d+_bot/.test(username) || username.endsWith("[bot]") + ); } // --------------------------------------------------------------------------- @@ -103,16 +115,235 @@ function inferSeverity(body: string): AutomatedComment["severity"] { ) { return "error"; } - if ( - lower.includes("warning") || - lower.includes("suggest") || - lower.includes("consider") - ) { + if (lower.includes("warning") || lower.includes("suggest") || lower.includes("consider")) { return "warning"; } return "info"; } +function getGitLabWebhookConfig(project: ProjectConfig) { + const webhook = project.scm?.webhook; + return { + enabled: webhook?.enabled !== false, + path: webhook?.path ?? "/api/webhooks/gitlab", + secretEnvVar: webhook?.secretEnvVar, + signatureHeader: webhook?.signatureHeader ?? "x-gitlab-token", + eventHeader: webhook?.eventHeader ?? "x-gitlab-event", + deliveryHeader: webhook?.deliveryHeader ?? "x-gitlab-event-uuid", + maxBodyBytes: webhook?.maxBodyBytes, + }; +} + +function verifyGitLabToken(secret: string, providedToken: string): boolean { + const toDigest = (value: string): Buffer => createHash("sha256").update(value).digest(); + return timingSafeEqual(toDigest(secret), toDigest(providedToken)); +} + +function parseGitLabRepository(payload: Record) { + const project = payload["project"]; + if (!project || typeof project !== "object") return undefined; + const projectRecord = project as Record; + const pathWithNamespace = projectRecord["path_with_namespace"]; + if (typeof pathWithNamespace === "string" && pathWithNamespace.length > 0) { + const parts = pathWithNamespace.split("/"); + if (parts.length >= 2) { + const name = parts[parts.length - 1]; + const owner = parts.slice(0, -1).join("/"); + if (owner && name) return { owner, name }; + } + } + const namespace = + typeof projectRecord["namespace"] === "string" ? projectRecord["namespace"] : undefined; + const name = typeof projectRecord["path"] === "string" ? projectRecord["path"] : undefined; + if (!namespace || !name) return undefined; + return { owner: namespace, name }; +} + +function isGitLabTagRef( + payload: Record, + objectAttributes: Record | undefined, +): boolean { + return ( + objectAttributes?.["tag"] === true || + payload["ref_type"] === "tag" || + payload["object_kind"] === "tag_push" + ); +} + +function parseGitLabCiBranch( + payload: Record, + objectAttributes: Record | undefined, +): string | undefined { + const isTag = isGitLabTagRef(payload, objectAttributes); + if (isTag) return undefined; + return parseWebhookBranchRef(payload["ref"] ?? objectAttributes?.["ref"]); +} + +function parseGitLabPushBranch( + payload: Record, + objectAttributes: Record | undefined, +): string | undefined { + const isTag = isGitLabTagRef(payload, objectAttributes); + if (isTag) return undefined; + const refValue = payload["ref"]; + return parseWebhookBranchRef(refValue); +} + +function parseGitLabWebhookEvent( + request: SCMWebhookRequest, + payload: Record, + config: ReturnType, +): SCMWebhookEvent | null { + const rawEventType = getWebhookHeader(request.headers, config.eventHeader); + if (!rawEventType) return null; + + const normalizedEventType = rawEventType.toLowerCase(); + const deliveryId = getWebhookHeader(request.headers, config.deliveryHeader); + const repository = parseGitLabRepository(payload); + const objectAttributes = + payload["object_attributes"] && typeof payload["object_attributes"] === "object" + ? (payload["object_attributes"] as Record) + : undefined; + const action = + typeof objectAttributes?.["action"] === "string" + ? (objectAttributes["action"] as string) + : typeof payload["action"] === "string" + ? (payload["action"] as string) + : rawEventType; + + if (normalizedEventType === "merge request hook" || payload["object_kind"] === "merge_request") { + const mergeRequest = + payload["object_attributes"] && typeof payload["object_attributes"] === "object" + ? (payload["object_attributes"] as Record) + : undefined; + if (!mergeRequest) return null; + return { + provider: "gitlab", + kind: "pull_request", + action, + rawEventType, + deliveryId, + repository, + prNumber: + typeof mergeRequest["iid"] === "number" + ? (mergeRequest["iid"] as number) + : typeof mergeRequest["id"] === "number" + ? (mergeRequest["id"] as number) + : undefined, + branch: parseWebhookBranchRef(mergeRequest["source_branch"]), + sha: + typeof mergeRequest["last_commit"] === "object" && mergeRequest["last_commit"] + ? ((mergeRequest["last_commit"] as Record)["id"] as string | undefined) + : undefined, + timestamp: parseWebhookTimestamp(mergeRequest["updated_at"]), + data: payload, + }; + } + + if (normalizedEventType === "note hook" || payload["object_kind"] === "note") { + const mergeRequest = + payload["merge_request"] && typeof payload["merge_request"] === "object" + ? (payload["merge_request"] as Record) + : undefined; + const noteableType = objectAttributes?.["noteable_type"]; + if (!mergeRequest || noteableType !== "MergeRequest") return null; + return { + provider: "gitlab", + kind: "comment", + action, + rawEventType, + deliveryId, + repository, + prNumber: + typeof mergeRequest["iid"] === "number" ? (mergeRequest["iid"] as number) : undefined, + branch: parseWebhookBranchRef(mergeRequest["source_branch"]), + sha: + typeof mergeRequest["last_commit"] === "object" && mergeRequest["last_commit"] + ? ((mergeRequest["last_commit"] as Record)["id"] as string | undefined) + : undefined, + timestamp: parseWebhookTimestamp( + objectAttributes?.["updated_at"] ?? objectAttributes?.["created_at"], + ), + data: payload, + }; + } + + if ( + normalizedEventType === "pipeline hook" || + normalizedEventType === "job hook" || + payload["object_kind"] === "pipeline" || + payload["object_kind"] === "build" + ) { + return { + provider: "gitlab", + kind: "ci", + action, + rawEventType, + deliveryId, + repository, + prNumber: + typeof payload["merge_request"] === "object" && payload["merge_request"] + ? (((payload["merge_request"] as Record)["iid"] as number | undefined) ?? + ((payload["merge_request"] as Record)["id"] as number | undefined)) + : undefined, + branch: parseGitLabCiBranch(payload, objectAttributes), + sha: + typeof payload["checkout_sha"] === "string" + ? (payload["checkout_sha"] as string) + : typeof payload["sha"] === "string" + ? (payload["sha"] as string) + : typeof objectAttributes?.["sha"] === "string" + ? (objectAttributes["sha"] as string) + : undefined, + timestamp: parseWebhookTimestamp( + objectAttributes?.["finished_at"] ?? + objectAttributes?.["updated_at"] ?? + payload["commit_timestamp"] ?? + payload["updated_at"], + ), + data: payload, + }; + } + + if ( + normalizedEventType === "push hook" || + normalizedEventType === "tag push hook" || + payload["object_kind"] === "push" || + payload["object_kind"] === "tag_push" + ) { + return { + provider: "gitlab", + kind: "push", + action, + rawEventType, + deliveryId, + repository, + branch: parseGitLabPushBranch(payload, objectAttributes), + sha: + typeof payload["after"] === "string" + ? (payload["after"] as string) + : typeof payload["checkout_sha"] === "string" + ? (payload["checkout_sha"] as string) + : undefined, + timestamp: parseWebhookTimestamp(payload["event_created_at"] ?? payload["commit_timestamp"]), + data: payload, + }; + } + + return { + provider: "gitlab", + kind: "unknown", + action, + rawEventType, + deliveryId, + repository, + timestamp: parseWebhookTimestamp( + objectAttributes?.["updated_at"] ?? payload["event_created_at"], + ), + data: payload, + }; +} + interface GitLabNote { id: number; author: { username: string }; @@ -137,10 +368,7 @@ async function fetchDiscussions( hostname: string | undefined, context: string, ): Promise { - const raw = await glab( - ["api", `${mrApiPath(pr)}/discussions?per_page=100`], - hostname, - ); + const raw = await glab(["api", `${mrApiPath(pr)}/discussions?per_page=100`], hostname); return parseJSON(raw, context); } @@ -158,6 +386,66 @@ function createGitLabSCM(config?: Record): SCM { return { name: "gitlab", + async verifyWebhook( + request: SCMWebhookRequest, + project: ProjectConfig, + ): Promise { + const webhookConfig = getGitLabWebhookConfig(project); + if (!webhookConfig.enabled) { + return { ok: false, reason: "Webhook is disabled for this project" }; + } + if (request.method.toUpperCase() !== "POST") { + return { ok: false, reason: "Webhook requests must use POST" }; + } + if ( + webhookConfig.maxBodyBytes !== undefined && + Buffer.byteLength(request.body, "utf8") > webhookConfig.maxBodyBytes + ) { + return { ok: false, reason: "Webhook payload exceeds configured maxBodyBytes" }; + } + + const eventType = getWebhookHeader(request.headers, webhookConfig.eventHeader); + if (!eventType) { + return { ok: false, reason: `Missing ${webhookConfig.eventHeader} header` }; + } + + const deliveryId = getWebhookHeader(request.headers, webhookConfig.deliveryHeader); + const secretName = webhookConfig.secretEnvVar; + if (!secretName) { + return { ok: true, deliveryId, eventType }; + } + + const secret = process.env[secretName]; + if (!secret) { + return { ok: false, reason: `Webhook secret env var ${secretName} is not configured` }; + } + + const providedToken = getWebhookHeader(request.headers, webhookConfig.signatureHeader); + if (!providedToken) { + return { ok: false, reason: `Missing ${webhookConfig.signatureHeader} header` }; + } + + if (!verifyGitLabToken(secret, providedToken)) { + return { + ok: false, + reason: "Webhook token verification failed", + deliveryId, + eventType, + }; + } + + return { ok: true, deliveryId, eventType }; + }, + + async parseWebhook( + request: SCMWebhookRequest, + project: ProjectConfig, + ): Promise { + const webhookConfig = getGitLabWebhookConfig(project); + const payload = parseWebhookJsonObject(request.body); + return parseGitLabWebhookEvent(request, payload, webhookConfig); + }, + async detectPR(session: Session, project: ProjectConfig): Promise { if (!session.branch) return null; @@ -258,10 +546,7 @@ function createGitLabSCM(config?: Record): SCM { const apiBase = mrApiPath(pr); const hostname = resolveHostname(pr); - const pipelinesRaw = await glab( - ["api", `${apiBase}/pipelines`], - hostname, - ); + const pipelinesRaw = await glab(["api", `${apiBase}/pipelines`], hostname); const pipelines = parseJSON>( pipelinesRaw, `getCIChecks pipelines for MR !${pr.number}`, @@ -303,12 +588,16 @@ function createGitLabSCM(config?: Record): SCM { try { checks = await this.getCIChecks(pr); } catch (err) { - console.warn(`getCISummary: CI check fetch failed for MR !${pr.number}: ${(err as Error).message}`); + console.warn( + `getCISummary: CI check fetch failed for MR !${pr.number}: ${(err as Error).message}`, + ); try { const state = await this.getPRState(pr); if (state === "merged" || state === "closed") return "none"; } catch (innerErr) { - console.warn(`getCISummary: PR state fallback also failed for MR !${pr.number}: ${(innerErr as Error).message}`); + console.warn( + `getCISummary: PR state fallback also failed for MR !${pr.number}: ${(innerErr as Error).message}`, + ); } return "failing"; } @@ -330,10 +619,7 @@ function createGitLabSCM(config?: Record): SCM { const hostname = resolveHostname(pr); const reviews: Review[] = []; - const approvalsRaw = await glab( - ["api", `${mrApiPath(pr)}/approvals`], - hostname, - ); + const approvalsRaw = await glab(["api", `${mrApiPath(pr)}/approvals`], hostname); const approvals = parseJSON<{ approved_by: Array<{ user: { username: string } }>; }>(approvalsRaw, `getReviews approvals for MR !${pr.number}`); @@ -367,17 +653,16 @@ function createGitLabSCM(config?: Record): SCM { }); } } catch (err) { - console.warn(`getReviews: discussions fetch failed for MR !${pr.number}: ${(err as Error).message}`); + console.warn( + `getReviews: discussions fetch failed for MR !${pr.number}: ${(err as Error).message}`, + ); } return reviews; }, async getReviewDecision(pr: PRInfo): Promise { - const raw = await glab( - ["api", `${mrApiPath(pr)}/approvals`], - resolveHostname(pr), - ); + const raw = await glab(["api", `${mrApiPath(pr)}/approvals`], resolveHostname(pr)); const data = parseJSON<{ approved: boolean; approvals_left: number }>( raw, `getReviewDecision for MR !${pr.number}`, @@ -476,10 +761,7 @@ function createGitLabSCM(config?: Record): SCM { }; } - const apiRaw = await glab( - ["api", mrApiPath(pr)], - hostname, - ); + const apiRaw = await glab(["api", mrApiPath(pr)], hostname); const apiData = parseJSON<{ merge_status: string; has_conflicts: boolean; diff --git a/packages/plugins/scm-gitlab/test/index.test.ts b/packages/plugins/scm-gitlab/test/index.test.ts index 91e82d482..a22036c2a 100644 --- a/packages/plugins/scm-gitlab/test/index.test.ts +++ b/packages/plugins/scm-gitlab/test/index.test.ts @@ -13,7 +13,7 @@ vi.mock("node:child_process", () => { }); import { create, manifest } from "../src/index.js"; -import type { PRInfo, Session, ProjectConfig } from "@composio/ao-core"; +import type { PRInfo, Session, ProjectConfig, SCMWebhookRequest } from "@composio/ao-core"; // --------------------------------------------------------------------------- // Fixtures @@ -65,6 +65,30 @@ function mockGlabError(msg = "Command failed") { glabMock.mockRejectedValueOnce(new Error(msg)); } +function makeWebhookRequest(overrides: Partial = {}): SCMWebhookRequest { + return { + method: "POST", + headers: { + "x-gitlab-event": "Merge Request Hook", + "x-gitlab-event-uuid": "delivery-1", + }, + body: JSON.stringify({ + object_kind: "merge_request", + object_attributes: { + action: "open", + iid: 42, + source_branch: "feat/my-feature", + updated_at: "2026-03-11T00:00:00Z", + last_commit: { id: "abc123" }, + }, + project: { + path_with_namespace: "acme/repo", + }, + }), + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -77,6 +101,7 @@ describe("scm-gitlab plugin", () => { vi.clearAllMocks(); warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); scm = create(); + delete process.env["GITLAB_WEBHOOK_SECRET"]; }); afterEach(() => { @@ -106,6 +131,186 @@ describe("scm-gitlab plugin", () => { }); }); + describe("verifyWebhook", () => { + it("accepts unsigned webhooks when no secret is configured", async () => { + await expect(scm.verifyWebhook?.(makeWebhookRequest(), project)).resolves.toEqual({ + ok: true, + deliveryId: "delivery-1", + eventType: "Merge Request Hook", + }); + }); + + it("verifies token when secret env var is configured", async () => { + process.env["GITLAB_WEBHOOK_SECRET"] = "topsecret"; + const result = await scm.verifyWebhook?.( + makeWebhookRequest({ + headers: { + "x-gitlab-event": "Merge Request Hook", + "x-gitlab-event-uuid": "delivery-1", + "x-gitlab-token": "topsecret", + }, + }), + { + ...project, + scm: { plugin: "gitlab", webhook: { secretEnvVar: "GITLAB_WEBHOOK_SECRET" } }, + }, + ); + expect(result?.ok).toBe(true); + }); + + it("rejects invalid token when secret env var is configured", async () => { + process.env["GITLAB_WEBHOOK_SECRET"] = "topsecret"; + const result = await scm.verifyWebhook?.( + makeWebhookRequest({ + headers: { + "x-gitlab-event": "Merge Request Hook", + "x-gitlab-event-uuid": "delivery-1", + "x-gitlab-token": "wrong", + }, + }), + { + ...project, + scm: { plugin: "gitlab", webhook: { secretEnvVar: "GITLAB_WEBHOOK_SECRET" } }, + }, + ); + expect(result).toEqual( + expect.objectContaining({ ok: false, reason: "Webhook token verification failed" }), + ); + }); + }); + + describe("parseWebhook", () => { + it("parses merge request hook events", async () => { + const event = await scm.parseWebhook?.(makeWebhookRequest(), project); + expect(event).toEqual( + expect.objectContaining({ + provider: "gitlab", + kind: "pull_request", + action: "open", + rawEventType: "Merge Request Hook", + prNumber: 42, + branch: "feat/my-feature", + sha: "abc123", + repository: { owner: "acme", name: "repo" }, + }), + ); + }); + + it("parses push hook events with branch and sha", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { + "x-gitlab-event": "Push Hook", + "x-gitlab-event-uuid": "delivery-2", + }, + body: JSON.stringify({ + object_kind: "push", + ref: "refs/heads/feat/my-feature", + after: "def456", + event_created_at: "2026-03-11T01:00:00Z", + project: { path_with_namespace: "acme/repo" }, + }), + }), + project, + ); + expect(event).toEqual( + expect.objectContaining({ + provider: "gitlab", + kind: "push", + branch: "feat/my-feature", + sha: "def456", + }), + ); + }); + + it("does not set branch for tag push refs", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { + "x-gitlab-event": "Tag Push Hook", + "x-gitlab-event-uuid": "delivery-3", + }, + body: JSON.stringify({ + object_kind: "tag_push", + ref: "refs/tags/v1.0.0", + after: "def456", + event_created_at: "2026-03-11T01:00:00Z", + project: { path_with_namespace: "acme/repo" }, + }), + }), + project, + ); + expect(event).toEqual( + expect.objectContaining({ + provider: "gitlab", + kind: "push", + branch: undefined, + sha: "def456", + }), + ); + }); + + it("does not set branch for plain tag push refs", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { + "x-gitlab-event": "Tag Push Hook", + "x-gitlab-event-uuid": "delivery-5", + }, + body: JSON.stringify({ + object_kind: "tag_push", + ref: "v1.0.0", + ref_type: "tag", + after: "def456", + event_created_at: "2026-03-11T01:00:00Z", + project: { path_with_namespace: "acme/repo" }, + }), + }), + project, + ); + expect(event).toEqual( + expect.objectContaining({ + provider: "gitlab", + kind: "push", + branch: undefined, + sha: "def456", + }), + ); + }); + + it("does not set branch for tag pipeline refs", async () => { + const event = await scm.parseWebhook?.( + makeWebhookRequest({ + headers: { + "x-gitlab-event": "Pipeline Hook", + "x-gitlab-event-uuid": "delivery-4", + }, + body: JSON.stringify({ + object_kind: "pipeline", + ref: "v1.0.0", + ref_type: "tag", + checkout_sha: "def456", + project: { path_with_namespace: "acme/repo" }, + object_attributes: { + ref: "v1.0.0", + tag: true, + updated_at: "2026-03-11T01:00:00Z", + }, + }), + }), + project, + ); + expect(event).toEqual( + expect.objectContaining({ + provider: "gitlab", + kind: "ci", + branch: undefined, + sha: "def456", + }), + ); + }); + }); + // ---- detectPR ---------------------------------------------------------- describe("detectPR", () => { @@ -157,9 +362,7 @@ describe("scm-gitlab plugin", () => { it("throws on invalid repo format", async () => { const badProject = { ...project, repo: "no-slash" }; - await expect(scm.detectPR(makeSession(), badProject)).rejects.toThrow( - "Invalid repo format", - ); + await expect(scm.detectPR(makeSession(), badProject)).rejects.toThrow("Invalid repo format"); }); it("correctly splits owner for subgroup repos", async () => { diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index d9cc2328c..5f23c96f6 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -3,7 +3,6 @@ import { NextRequest } from "next/server"; import { SessionNotFoundError, SessionNotRestorableError, - SessionNotFoundError, type Session, type SessionManager, type OrchestratorConfig, @@ -103,6 +102,22 @@ const mockSessionManager: SessionManager = { const mockSCM: SCM = { name: "github", + verifyWebhook: vi.fn(async () => ({ + ok: true, + eventType: "pull_request", + deliveryId: "delivery-1", + })), + parseWebhook: vi.fn(async () => ({ + provider: "github", + kind: "pull_request" as const, + action: "opened", + rawEventType: "pull_request", + deliveryId: "delivery-1", + repository: { owner: "acme", name: "my-app" }, + prNumber: 432, + branch: "feat/health-check", + data: {}, + })), detectPR: vi.fn(async () => null), getPRState: vi.fn(async () => "open" as const), mergePR: vi.fn(async () => {}), @@ -131,6 +146,7 @@ const mockRegistry: PluginRegistry = { }; const mockLifecycleManager = { + check: vi.fn(async () => {}), getStates: vi.fn(() => new Map()), }; @@ -146,7 +162,7 @@ const mockConfig: OrchestratorConfig = { path: "/tmp/my-app", defaultBranch: "main", sessionPrefix: "my-app", - scm: { plugin: "github" }, + scm: { plugin: "github", webhook: {} }, }, }, notifiers: {}, @@ -183,6 +199,7 @@ import { POST as restorePOST } from "@/app/api/sessions/[id]/restore/route"; import { POST as remapPOST } from "@/app/api/sessions/[id]/remap/route"; import { POST as mergePOST } from "@/app/api/prs/[id]/merge/route"; import { GET as eventsGET } from "@/app/api/events/route"; +import { POST as webhookPOST } from "@/app/api/webhooks/[...slug]/route"; function makeRequest(url: string, init?: RequestInit): NextRequest { return new NextRequest( @@ -198,6 +215,22 @@ beforeEach(() => { (mockSessionManager.get as ReturnType).mockImplementation( async (id: string) => testSessions.find((s) => s.id === id) ?? null, ); + (mockSCM.verifyWebhook as ReturnType).mockResolvedValue({ + ok: true, + eventType: "pull_request", + deliveryId: "delivery-1", + }); + (mockSCM.parseWebhook as ReturnType).mockResolvedValue({ + provider: "github", + kind: "pull_request", + action: "opened", + rawEventType: "pull_request", + deliveryId: "delivery-1", + repository: { owner: "acme", name: "my-app" }, + prNumber: 432, + branch: "feat/health-check", + data: {}, + }); }); describe("API Routes", () => { @@ -851,4 +884,204 @@ describe("API Routes", () => { expect(data.projects[0].name).toBe("My App"); }); }); + + describe("POST /api/webhooks/[...slug]", () => { + it("verifies webhook and triggers lifecycle checks for matching sessions", async () => { + const req = makeRequest("/api/webhooks/github", { + method: "POST", + body: JSON.stringify({ any: "payload" }), + headers: { + "Content-Type": "application/json", + "x-github-event": "pull_request", + "x-github-delivery": "delivery-1", + }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(202); + expect(mockLifecycleManager.check).toHaveBeenCalledWith("backend-7"); + const data = await res.json(); + expect(data.sessionIds).toEqual(["backend-7"]); + }); + + it("returns 401 when webhook verification fails", async () => { + (mockSCM.verifyWebhook as ReturnType).mockResolvedValueOnce({ + ok: false, + reason: "Webhook signature verification failed", + }); + + const req = makeRequest("/api/webhooks/github", { + method: "POST", + body: JSON.stringify({ any: "payload" }), + headers: { + "Content-Type": "application/json", + "x-github-event": "pull_request", + }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(401); + expect(mockLifecycleManager.check).not.toHaveBeenCalled(); + }); + + it("returns 404 when no project is configured for the webhook path", async () => { + const req = makeRequest("/api/webhooks/gitlab", { + method: "POST", + body: JSON.stringify({ any: "payload" }), + headers: { "Content-Type": "application/json" }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(404); + }); + + it("returns 413 when content-length exceeds configured maxBodyBytes", async () => { + const originalWebhook = mockConfig.projects["my-app"]?.scm?.webhook; + if (mockConfig.projects["my-app"]?.scm) { + mockConfig.projects["my-app"].scm.webhook = { maxBodyBytes: 1 }; + } + + const req = makeRequest("/api/webhooks/github", { + method: "POST", + body: JSON.stringify({ any: "payload" }), + headers: { + "Content-Type": "application/json", + "Content-Length": "50", + "x-github-event": "pull_request", + }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(413); + + if (mockConfig.projects["my-app"]?.scm) { + mockConfig.projects["my-app"].scm.webhook = originalWebhook; + } + }); + + it("does not apply early 413 when any matching candidate is unbounded", async () => { + const originalMyAppWebhook = mockConfig.projects["my-app"]?.scm?.webhook; + const originalOtherProject = mockConfig.projects["other-app"]; + + if (mockConfig.projects["my-app"]?.scm) { + mockConfig.projects["my-app"].scm.webhook = { + path: "/api/webhooks/github", + maxBodyBytes: 1, + }; + } + mockConfig.projects["other-app"] = { + name: "Other App", + repo: "acme/other-app", + path: "/tmp/other-app", + defaultBranch: "main", + sessionPrefix: "other-app", + scm: { plugin: "github", webhook: { path: "/api/webhooks/github" } }, + }; + + const req = makeRequest("/api/webhooks/github", { + method: "POST", + body: JSON.stringify({ any: "payload" }), + headers: { + "Content-Type": "application/json", + "Content-Length": "50", + "x-github-event": "pull_request", + }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(202); + + if (mockConfig.projects["my-app"]?.scm) { + mockConfig.projects["my-app"].scm.webhook = originalMyAppWebhook; + } + if (originalOtherProject) { + mockConfig.projects["other-app"] = originalOtherProject; + } else { + delete mockConfig.projects["other-app"]; + } + }); + + it("continues after parse errors and still returns 202", async () => { + (mockSCM.parseWebhook as ReturnType).mockRejectedValueOnce( + new Error("Invalid webhook payload"), + ); + + const req = makeRequest("/api/webhooks/github", { + method: "POST", + body: JSON.stringify({ any: "payload" }), + headers: { + "Content-Type": "application/json", + "x-github-event": "pull_request", + }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(202); + const data = await res.json(); + expect(Array.isArray(data.parseErrors)).toBe(true); + expect(data.parseErrors[0]).toMatch(/Invalid webhook payload/); + }); + + it("continues lifecycle checks when one session check throws", async () => { + const matchingSessions: Session[] = [ + makeSession({ + id: "backend-7", + projectId: "my-app", + status: "working", + activity: "active", + pr: { + number: 432, + url: "https://github.com/acme/my-app/pull/432", + title: "feat: health check", + owner: "acme", + repo: "my-app", + branch: "feat/health-check", + baseBranch: "main", + isDraft: false, + }, + }), + makeSession({ + id: "backend-8", + projectId: "my-app", + status: "working", + activity: "active", + pr: { + number: 432, + url: "https://github.com/acme/my-app/pull/432", + title: "feat: health check", + owner: "acme", + repo: "my-app", + branch: "feat/health-check", + baseBranch: "main", + isDraft: false, + }, + }), + ]; + + (mockSessionManager.list as ReturnType).mockResolvedValueOnce(matchingSessions); + (mockLifecycleManager.check as ReturnType) + .mockRejectedValueOnce(new Error("check failed")) + .mockResolvedValueOnce(undefined); + + const req = makeRequest("/api/webhooks/github", { + method: "POST", + body: JSON.stringify({ any: "payload" }), + headers: { + "Content-Type": "application/json", + "x-github-event": "pull_request", + }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(202); + expect(mockLifecycleManager.check).toHaveBeenCalledTimes(2); + + const data = await res.json(); + expect(data.sessionIds).toContain("backend-7"); + expect(data.sessionIds).toContain("backend-8"); + expect(Array.isArray(data.lifecycleErrors)).toBe(true); + expect(data.lifecycleErrors[0]).toContain("backend-7"); + expect(data.lifecycleErrors[0]).toContain("check failed"); + }); + }); }); diff --git a/packages/web/src/app/api/webhooks/[...slug]/route.ts b/packages/web/src/app/api/webhooks/[...slug]/route.ts new file mode 100644 index 000000000..9ccf878ad --- /dev/null +++ b/packages/web/src/app/api/webhooks/[...slug]/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { getServices } from "@/lib/services"; +import { + buildWebhookRequest, + eventMatchesProject, + findAffectedSessions, + findWebhookProjects, +} from "@/lib/scm-webhooks"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + try { + const services = await getServices(); + const path = new URL(request.url).pathname; + const candidates = findWebhookProjects(services.config, services.registry, path); + + if (candidates.length === 0) { + return NextResponse.json( + { error: "No SCM webhook configured for this path" }, + { status: 404 }, + ); + } + + const rawContentLength = request.headers.get("content-length"); + const contentLength = rawContentLength ? Number(rawContentLength) : NaN; + const candidateMaxBodyBytes = candidates.map( + (candidate) => candidate.project.scm?.webhook?.maxBodyBytes, + ); + const allCandidatesBounded = candidateMaxBodyBytes.every((value) => typeof value === "number"); + const maxBodyBytes = allCandidatesBounded + ? Math.max(...(candidateMaxBodyBytes as number[])) + : undefined; + if ( + maxBodyBytes !== undefined && + Number.isFinite(contentLength) && + contentLength > maxBodyBytes + ) { + return NextResponse.json( + { error: "Webhook payload exceeds configured maxBodyBytes" }, + { status: 413 }, + ); + } + + const rawBody = new Uint8Array(await request.arrayBuffer()); + const body = new TextDecoder().decode(rawBody); + const webhookRequest = buildWebhookRequest(request, body, rawBody); + + const sessions = await services.sessionManager.list(); + const sessionIds = new Set(); + const projectIds = new Set(); + let verified = false; + const errors: string[] = []; + const parseErrors: string[] = []; + const lifecycleErrors: string[] = []; + + for (const candidate of candidates) { + const verification = await candidate.scm.verifyWebhook?.(webhookRequest, candidate.project); + if (!verification?.ok) { + if (verification?.reason) errors.push(verification.reason); + continue; + } + verified = true; + + let event; + try { + event = await candidate.scm.parseWebhook?.(webhookRequest, candidate.project); + } catch (err) { + parseErrors.push(err instanceof Error ? err.message : "Invalid webhook payload"); + continue; + } + + if (!event || !eventMatchesProject(event, candidate.project)) { + continue; + } + + projectIds.add(candidate.projectId); + const affectedSessions = findAffectedSessions(sessions, candidate.projectId, event); + if (affectedSessions.length === 0) { + continue; + } + + const lifecycle = services.lifecycleManager; + for (const session of affectedSessions) { + sessionIds.add(session.id); + try { + await lifecycle.check(session.id); + } catch (err) { + const message = err instanceof Error ? err.message : "Lifecycle check failed"; + lifecycleErrors.push(`session ${session.id}: ${message}`); + } + } + } + + if (!verified) { + return NextResponse.json( + { error: errors[0] ?? "Webhook verification failed", ok: false }, + { status: 401 }, + ); + } + + return NextResponse.json( + { + ok: true, + projectIds: [...projectIds], + sessionIds: [...sessionIds], + matchedSessions: sessionIds.size, + parseErrors, + lifecycleErrors, + }, + { status: 202 }, + ); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to process SCM webhook" }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/lib/scm-webhooks.test.ts b/packages/web/src/lib/scm-webhooks.test.ts new file mode 100644 index 000000000..8f4163e61 --- /dev/null +++ b/packages/web/src/lib/scm-webhooks.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { ProjectConfig, SCMWebhookEvent, Session } from "@composio/ao-core"; +import { eventMatchesProject, findAffectedSessions } from "./scm-webhooks"; + +const project: ProjectConfig = { + name: "my-app", + repo: "acme/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "my-app", +}; + +describe("eventMatchesProject", () => { + it("matches when repository owner/name equals project repo", () => { + const event: SCMWebhookEvent = { + provider: "github", + kind: "pull_request", + action: "opened", + rawEventType: "pull_request", + repository: { owner: "acme", name: "my-app" }, + data: {}, + }; + + expect(eventMatchesProject(event, project)).toBe(true); + }); + + it("matches repository names case-insensitively", () => { + const event: SCMWebhookEvent = { + provider: "github", + kind: "pull_request", + action: "opened", + rawEventType: "pull_request", + repository: { owner: "AcMe", name: "My-App" }, + data: {}, + }; + + expect(eventMatchesProject(event, project)).toBe(true); + }); + + it("does not match when repository is missing", () => { + const event: SCMWebhookEvent = { + provider: "github", + kind: "unknown", + action: "noop", + rawEventType: "unknown", + data: {}, + }; + + expect(eventMatchesProject(event, project)).toBe(false); + }); +}); + +describe("findAffectedSessions", () => { + it("skips terminal sessions even when branch/pr match", () => { + const sessions: Session[] = [ + { + id: "s1", + projectId: "my-app", + status: "working", + activity: "active", + branch: "feat/one", + issueId: null, + pr: { + number: 1, + url: "u", + title: "t", + owner: "acme", + repo: "my-app", + branch: "feat/one", + baseBranch: "main", + isDraft: false, + }, + workspacePath: null, + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }, + { + id: "s2", + projectId: "my-app", + status: "merged", + activity: "exited", + branch: "feat/one", + issueId: null, + pr: { + number: 1, + url: "u", + title: "t", + owner: "acme", + repo: "my-app", + branch: "feat/one", + baseBranch: "main", + isDraft: false, + }, + workspacePath: null, + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }, + ]; + + const event: SCMWebhookEvent = { + provider: "github", + kind: "pull_request", + action: "synchronize", + rawEventType: "pull_request", + prNumber: 1, + data: {}, + }; + + const affected = findAffectedSessions(sessions, "my-app", event); + expect(affected.map((s) => s.id)).toEqual(["s1"]); + }); +}); diff --git a/packages/web/src/lib/scm-webhooks.ts b/packages/web/src/lib/scm-webhooks.ts new file mode 100644 index 000000000..105dca848 --- /dev/null +++ b/packages/web/src/lib/scm-webhooks.ts @@ -0,0 +1,82 @@ +import { + TERMINAL_STATUSES, + type OrchestratorConfig, + type PluginRegistry, + type ProjectConfig, + type SCM, + type SCMWebhookEvent, + type SCMWebhookRequest, + type Session, +} from "@composio/ao-core"; + +export interface WebhookProjectMatch { + projectId: string; + project: ProjectConfig; + scm: SCM; +} + +function requestHeadersToRecord(headers: Headers): Record { + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + return record; +} + +function getProjectWebhookPath(project: ProjectConfig): string | null { + if (!project.scm?.webhook || project.scm.webhook.enabled === false) return null; + return project.scm.webhook.path ?? `/api/webhooks/${project.scm.plugin}`; +} + +export function findWebhookProjects( + config: OrchestratorConfig, + registry: PluginRegistry, + pathname: string, +): WebhookProjectMatch[] { + return Object.entries(config.projects).flatMap(([projectId, project]) => { + if (!project.scm) return []; + const webhookPath = getProjectWebhookPath(project); + if (!webhookPath || webhookPath !== pathname) return []; + const scm = registry.get("scm", project.scm.plugin); + if (!scm?.parseWebhook || !scm.verifyWebhook) return []; + return [{ projectId, project, scm }]; + }); +} + +export function eventMatchesProject(event: SCMWebhookEvent, project: ProjectConfig): boolean { + if (!event.repository) return false; + return ( + `${event.repository.owner}/${event.repository.name}`.toLowerCase() === + project.repo.toLowerCase() + ); +} + +export function findAffectedSessions( + sessions: Session[], + projectId: string, + event: SCMWebhookEvent, +): Session[] { + return sessions.filter((session) => { + if (session.projectId !== projectId) return false; + if (TERMINAL_STATUSES.has(session.status)) return false; + if (event.prNumber !== undefined && session.pr?.number === event.prNumber) return true; + if (event.branch && session.branch === event.branch) return true; + return false; + }); +} + +export function buildWebhookRequest( + request: Request, + body: string, + rawBody: Uint8Array, +): SCMWebhookRequest { + const url = new URL(request.url); + return { + method: request.method, + headers: requestHeadersToRecord(request.headers), + body, + rawBody, + path: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + }; +}