feat: add SCM webhook lifecycle triggers (#394)

* feat(core): add scm webhook contract

Defines a provider-agnostic SCM webhook contract in core types and
config so SCM plugins can verify and normalize inbound webhook events
without reshaping project config later.

* feat(scm): trigger lifecycle checks from github webhooks

Adds GitHub webhook verification and event parsing, exposes a web
webhook endpoint, and routes matching PR/branch events through the
existing lifecycle manager so CI and review reactions update immediately.

* fix(scm): verify github signatures with raw webhook bytes

Preserves the original webhook bytes alongside the decoded payload so
GitHub HMAC verification uses the exact request body while the route
continues to drive lifecycle checks through the existing manager.

* fix(web): wire scm webhook route into main branch services

Restores the main-branch service and route-test wiring while keeping the
new webhook route coverage and scoped lifecycle helper in place.

* fix(webhooks): use singleton lifecycle manager and fail closed on scm API errors

Reuses the existing services lifecycle manager for webhook-triggered checks
so reactions and state transitions don't replay from a fresh instance, and
restores fail-closed behavior for GitHub review comment fetch failures.

* fix(webhooks): tighten project matching and restore scm compatibility methods

Prevents repository-less webhook events from matching all projects, restores
GitHub SCM PR utility methods and CI status rollup fallback, and adds tests
covering the compatibility paths and safer project matching behavior.

* fix(webhooks): pre-check content length and continue on parse errors

Adds an early content-length guard against configured maxBodyBytes before
reading the body and changes candidate parse failures to fail-forward so
one malformed payload path does not abort other valid candidate handling.

* fix(scm-github): parse review-comment timestamps from comment payload

Use comment.updated_at/created_at for pull_request_review_comment webhook
timestamps so normalized events retain temporal data for comment events.

* fix(webhooks): apply early size guard only when all candidates are bounded

Uses the broadest candidate limit for pre-read content-length checks and
skips early rejection when any matching project has no configured limit,
while retaining per-candidate verification limits.

* fix(webhooks): normalize repo matching and skip terminal sessions

Match webhook repository names case-insensitively against configured project
repos and avoid lifecycle checks for terminal sessions when resolving
webhook-affected sessions.

* fix(webhooks): fail forward when lifecycle checks throw

* fix(scm-github): parse push webhook branch and sha

* refactor(scm-github): dedupe cli exec helper wrappers

* fix(scm-github): tighten exec helper type and comment timestamps

* fix(scm-github): prefer head_commit timestamp for push events

* fix(webhooks): tighten repository parsing and helper visibility

* fix(scm-github): ignore non-head refs for push branch

* chore: trigger bugbot rerun

* feat(scm-gitlab): add webhook verification and event parsing

* fix(webhooks): share parser utils and handle check_run branch

* fix(webhooks): ignore gitlab tag refs in ci branch mapping

* fix(scm-gitlab): harden token and tag ref handling

* chore(scm-gitlab): update webhook helpers around bugbot threads
This commit is contained in:
Harsh Batheja 2026-03-11 10:34:41 +05:30 committed by deepak
parent b3ff0d9b85
commit 4fd8cac7f9
15 changed files with 1906 additions and 223 deletions

View File

@ -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:

View File

@ -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": [

View File

@ -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 = {

View File

@ -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();

View File

@ -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";

View File

@ -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<string, unknown> {
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<string, unknown>;
}
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;
}

View File

@ -518,6 +518,16 @@ export interface CreateIssueInput {
export interface SCM {
readonly name: string;
verifyWebhook?(
request: SCMWebhookRequest,
project: ProjectConfig,
): Promise<SCMWebhookVerificationResult>;
parseWebhook?(
request: SCMWebhookRequest,
project: ProjectConfig,
): Promise<SCMWebhookEvent | null>;
// --- 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<string, string | string[] | undefined>;
body: string;
rawBody?: Uint8Array;
path?: string;
query?: Record<string, string | string[] | undefined>;
}
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<string, unknown>;
}
// --- 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;

View File

@ -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<string> {
async function execCli(bin: ExecCommand, args: string[], cwd?: string): Promise<string> {
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<string> {
}
async function ghInDir(args: string[], cwd: string): Promise<string> {
return execCli("gh", args, { cwd });
return execCli("gh", args, cwd);
}
async function git(args: string[], cwd: string): Promise<string> {
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<CICheck[]> {
.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<string, unknown>) {
const repository = payload["repository"];
if (!repository || typeof repository !== "object") return undefined;
const repo = repository as Record<string, unknown>;
const ownerValue = repo["owner"];
const ownerLogin =
ownerValue && typeof ownerValue === "object"
? (ownerValue as Record<string, unknown>)["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<string, unknown>,
config: ReturnType<typeof getGitHubWebhookConfig>,
): 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<string, unknown>;
const head = pr["head"] as Record<string, unknown> | 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<string, unknown>;
const head = pr["head"] as Record<string, unknown> | 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<string, unknown> | undefined)?.["submitted_at"],
)
: parseWebhookTimestamp(
(payload["comment"] as Record<string, unknown> | undefined)?.["updated_at"] ??
(payload["comment"] as Record<string, unknown> | 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<string, unknown>;
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<string, unknown> | undefined)?.["updated_at"] ??
(payload["comment"] as Record<string, unknown> | undefined)?.["created_at"],
),
data: payload,
};
}
if (rawEventType === "check_run" || rawEventType === "check_suite") {
const check = payload[rawEventType] as Record<string, unknown> | undefined;
const pullRequests = Array.isArray(check?.["pull_requests"])
? (check?.["pull_requests"] as Array<Record<string, unknown>>)
: [];
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<string, unknown> | undefined)?.[
"head_branch"
] === "string"
? ((check?.["check_suite"] as Record<string, unknown>)["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<Record<string, unknown>>)
: [];
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<string, unknown>)
: 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<SCMWebhookVerificationResult> {
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<SCMWebhookEvent | null> {
const config = getGitHubWebhookConfig(project);
const payload = parseWebhookJsonObject(request.body);
return parseGitHubWebhookEvent(request, payload, config);
},
async detectPR(session: Session, project: ProjectConfig): Promise<PRInfo | null> {
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 });
}
},

View File

@ -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> = {}): 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",

View File

@ -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<string, unknown>) {
const project = payload["project"];
if (!project || typeof project !== "object") return undefined;
const projectRecord = project as Record<string, unknown>;
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<string, unknown>,
objectAttributes: Record<string, unknown> | undefined,
): boolean {
return (
objectAttributes?.["tag"] === true ||
payload["ref_type"] === "tag" ||
payload["object_kind"] === "tag_push"
);
}
function parseGitLabCiBranch(
payload: Record<string, unknown>,
objectAttributes: Record<string, unknown> | undefined,
): string | undefined {
const isTag = isGitLabTagRef(payload, objectAttributes);
if (isTag) return undefined;
return parseWebhookBranchRef(payload["ref"] ?? objectAttributes?.["ref"]);
}
function parseGitLabPushBranch(
payload: Record<string, unknown>,
objectAttributes: Record<string, unknown> | 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<string, unknown>,
config: ReturnType<typeof getGitLabWebhookConfig>,
): 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<string, unknown>)
: 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<string, unknown>)
: 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<string, unknown>)["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<string, unknown>)
: 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<string, unknown>)["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<string, unknown>)["iid"] as number | undefined) ??
((payload["merge_request"] as Record<string, unknown>)["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<GitLabDiscussion[]> {
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<GitLabDiscussion[]>(raw, context);
}
@ -158,6 +386,66 @@ function createGitLabSCM(config?: Record<string, unknown>): SCM {
return {
name: "gitlab",
async verifyWebhook(
request: SCMWebhookRequest,
project: ProjectConfig,
): Promise<SCMWebhookVerificationResult> {
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<SCMWebhookEvent | null> {
const webhookConfig = getGitLabWebhookConfig(project);
const payload = parseWebhookJsonObject(request.body);
return parseGitLabWebhookEvent(request, payload, webhookConfig);
},
async detectPR(session: Session, project: ProjectConfig): Promise<PRInfo | null> {
if (!session.branch) return null;
@ -258,10 +546,7 @@ function createGitLabSCM(config?: Record<string, unknown>): 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<Array<{ id: number }>>(
pipelinesRaw,
`getCIChecks pipelines for MR !${pr.number}`,
@ -303,12 +588,16 @@ function createGitLabSCM(config?: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<ReviewDecision> {
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<string, unknown>): 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;

View File

@ -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> = {}): 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 () => {

View File

@ -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<typeof vi.fn>).mockImplementation(
async (id: string) => testSessions.find((s) => s.id === id) ?? null,
);
(mockSCM.verifyWebhook as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
eventType: "pull_request",
deliveryId: "delivery-1",
});
(mockSCM.parseWebhook as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(matchingSessions);
(mockLifecycleManager.check as ReturnType<typeof vi.fn>)
.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");
});
});
});

View File

@ -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<Response> {
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<string>();
const projectIds = new Set<string>();
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 },
);
}
}

View File

@ -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"]);
});
});

View File

@ -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<string, string> {
const record: Record<string, string> = {};
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>("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()),
};
}