Skip to content

Commit 2064595

Browse files
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
1 parent c7c04c1 commit 2064595

File tree

15 files changed

+1900
-217
lines changed

15 files changed

+1900
-217
lines changed

agent-orchestrator.yaml.example

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ projects:
3636
# plugin: linear
3737
# teamId: "your-team-id"
3838

39+
# SCM webhook acceleration (optional)
40+
# scm:
41+
# plugin: github
42+
# webhook:
43+
# path: /api/webhooks/github
44+
# secretEnvVar: GITHUB_WEBHOOK_SECRET
45+
# signatureHeader: x-hub-signature-256
46+
# eventHeader: x-github-event
47+
# deliveryHeader: x-github-delivery
48+
# maxBodyBytes: 1048576
49+
3950
# Files to symlink into workspaces
4051
# symlinks: [.env, .claude]
4152

@@ -45,11 +56,7 @@ projects:
4556

4657
# Agent-specific config
4758
# agentConfig:
48-
# permissions: permissionless # modes: permissionless | default | auto-edit | suggest
49-
# # - permissionless: no interactive permission prompts
50-
# # - default: agent defaults
51-
# # - auto-edit: auto-approve edits where supported
52-
# # - suggest: conservative/untrusted-approval mode where supported
59+
# permissions: skip # --dangerously-skip-permissions
5360
# model: opus
5461

5562
# Inline rules included in every agent prompt for this project
@@ -67,10 +74,6 @@ projects:
6774
# OpenCode issue session strategy (only for agent: opencode)
6875
# opencodeIssueSessionStrategy: reuse # reuse | delete | ignore
6976

70-
# OpenCode orchestrator session strategy (only for agent: opencode)
71-
# Controls how the orchestrator's OpenCode session is managed
72-
# orchestratorSessionStrategy: reuse # reuse | delete | ignore
73-
7477
# Per-project reaction overrides
7578
# reactions:
7679
# approved-and-green:

packages/core/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"./utils": {
1919
"types": "./dist/utils.d.ts",
2020
"import": "./dist/utils.js"
21+
},
22+
"./scm-webhook-utils": {
23+
"types": "./dist/scm-webhook-utils.d.ts",
24+
"import": "./dist/scm-webhook-utils.js"
2125
}
2226
},
2327
"files": [

packages/core/src/__tests__/config-validation.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,64 @@ describe("Config Validation - Session Prefix Regex", () => {
274274
});
275275
});
276276

277+
describe("Config Validation - SCM webhook contract", () => {
278+
it("accepts a project scm webhook block and defaults enabled=true", () => {
279+
const config = validateConfig({
280+
projects: {
281+
proj1: {
282+
path: "/repos/test",
283+
repo: "org/test",
284+
defaultBranch: "main",
285+
scm: {
286+
plugin: "github",
287+
webhook: {
288+
path: "/api/webhooks/github",
289+
secretEnvVar: "GITHUB_WEBHOOK_SECRET",
290+
eventHeader: "x-github-event",
291+
deliveryHeader: "x-github-delivery",
292+
signatureHeader: "x-hub-signature-256",
293+
maxBodyBytes: 1048576,
294+
},
295+
},
296+
},
297+
},
298+
});
299+
300+
expect(config.projects["proj1"]?.scm).toEqual({
301+
plugin: "github",
302+
webhook: {
303+
enabled: true,
304+
path: "/api/webhooks/github",
305+
secretEnvVar: "GITHUB_WEBHOOK_SECRET",
306+
eventHeader: "x-github-event",
307+
deliveryHeader: "x-github-delivery",
308+
signatureHeader: "x-hub-signature-256",
309+
maxBodyBytes: 1048576,
310+
},
311+
});
312+
});
313+
314+
it("rejects non-positive scm webhook maxBodyBytes", () => {
315+
expect(() =>
316+
validateConfig({
317+
projects: {
318+
proj1: {
319+
path: "/repos/test",
320+
repo: "org/test",
321+
defaultBranch: "main",
322+
scm: {
323+
plugin: "github",
324+
webhook: {
325+
maxBodyBytes: 0,
326+
},
327+
},
328+
},
329+
},
330+
}),
331+
).toThrow();
332+
});
333+
});
334+
277335
describe("Config Schema Validation", () => {
278336
it("requires projects field", () => {
279337
const config = {

packages/core/src/config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ const TrackerConfigSchema = z
7070
const SCMConfigSchema = z
7171
.object({
7272
plugin: z.string(),
73+
webhook: z
74+
.object({
75+
enabled: z.boolean().default(true),
76+
path: z.string().optional(),
77+
secretEnvVar: z.string().optional(),
78+
signatureHeader: z.string().optional(),
79+
eventHeader: z.string().optional(),
80+
deliveryHeader: z.string().optional(),
81+
maxBodyBytes: z.number().int().positive().optional(),
82+
})
83+
.optional(),
7384
})
7485
.passthrough();
7586

packages/core/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ export {
9595
normalizeRetryConfig,
9696
readLastJsonlEntry,
9797
} from "./utils.js";
98+
export {
99+
getWebhookHeader,
100+
parseWebhookJsonObject,
101+
parseWebhookTimestamp,
102+
parseWebhookBranchRef,
103+
} from "./scm-webhook-utils.js";
98104
export { asValidOpenCodeSessionId } from "./opencode-session-id.js";
99105
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
100106
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { SCMWebhookRequest } from "./types.js";
2+
3+
export function getWebhookHeader(
4+
headers: SCMWebhookRequest["headers"],
5+
name: string,
6+
): string | undefined {
7+
const target = name.toLowerCase();
8+
for (const [key, value] of Object.entries(headers)) {
9+
if (key.toLowerCase() !== target) continue;
10+
if (Array.isArray(value)) return value[0];
11+
return value;
12+
}
13+
return undefined;
14+
}
15+
16+
export function parseWebhookJsonObject(body: string): Record<string, unknown> {
17+
const parsed: unknown = JSON.parse(body);
18+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
19+
throw new Error("Webhook payload must be a JSON object");
20+
}
21+
return parsed as Record<string, unknown>;
22+
}
23+
24+
export function parseWebhookTimestamp(value: unknown): Date | undefined {
25+
if (typeof value !== "string") return undefined;
26+
const date = new Date(value);
27+
return Number.isNaN(date.getTime()) ? undefined : date;
28+
}
29+
30+
export function parseWebhookBranchRef(ref: unknown): string | undefined {
31+
if (typeof ref !== "string" || ref.length === 0) return undefined;
32+
if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length);
33+
if (ref.startsWith("refs/")) return undefined;
34+
return ref;
35+
}

packages/core/src/types.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,16 @@ export interface CreateIssueInput {
518518
export interface SCM {
519519
readonly name: string;
520520

521+
verifyWebhook?(
522+
request: SCMWebhookRequest,
523+
project: ProjectConfig,
524+
): Promise<SCMWebhookVerificationResult>;
525+
526+
parseWebhook?(
527+
request: SCMWebhookRequest,
528+
project: ProjectConfig,
529+
): Promise<SCMWebhookEvent | null>;
530+
521531
// --- PR Lifecycle ---
522532

523533
/** Detect if a session has an open PR (by branch name) */
@@ -601,6 +611,42 @@ export const PR_STATE = {
601611

602612
export type MergeMethod = "merge" | "squash" | "rebase";
603613

614+
export interface SCMWebhookRequest {
615+
method: string;
616+
headers: Record<string, string | string[] | undefined>;
617+
body: string;
618+
rawBody?: Uint8Array;
619+
path?: string;
620+
query?: Record<string, string | string[] | undefined>;
621+
}
622+
623+
export interface SCMWebhookVerificationResult {
624+
ok: boolean;
625+
reason?: string;
626+
deliveryId?: string;
627+
eventType?: string;
628+
}
629+
630+
export type SCMWebhookEventKind = "pull_request" | "ci" | "review" | "comment" | "push" | "unknown";
631+
632+
export interface SCMWebhookEvent {
633+
provider: string;
634+
kind: SCMWebhookEventKind;
635+
action: string;
636+
rawEventType: string;
637+
deliveryId?: string;
638+
projectId?: string;
639+
repository?: {
640+
owner: string;
641+
name: string;
642+
};
643+
prNumber?: number;
644+
branch?: string;
645+
sha?: string;
646+
timestamp?: Date;
647+
data: Record<string, unknown>;
648+
}
649+
604650
// --- CI Types ---
605651

606652
export interface CICheck {
@@ -951,9 +997,20 @@ export interface TrackerConfig {
951997

952998
export interface SCMConfig {
953999
plugin: string;
1000+
webhook?: SCMWebhookConfig;
9541001
[key: string]: unknown;
9551002
}
9561003

1004+
export interface SCMWebhookConfig {
1005+
enabled?: boolean;
1006+
path?: string;
1007+
secretEnvVar?: string;
1008+
signatureHeader?: string;
1009+
eventHeader?: string;
1010+
deliveryHeader?: string;
1011+
maxBodyBytes?: number;
1012+
}
1013+
9571014
export interface NotifierConfig {
9581015
plugin: string;
9591016
[key: string]: unknown;

0 commit comments

Comments
 (0)