Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1767,7 +1767,7 @@ function WebhookPage({
];
return (
<section className="grid gap-4">
<PageTitle icon={<Activity className="h-5 w-5 text-muted" aria-hidden />} title="GitHub webhooks" subtitle={summary?.mode === "canary" ? "Canary ingestion health. Only enabled repositories may create jobs." : "Shadow ingestion health. Deliveries are observed but do not create jobs."} action={<RefreshButton onClick={onRefresh} />} />
<PageTitle icon={<Activity className="h-5 w-5 text-muted" aria-hidden />} title="GitHub webhooks" subtitle={summary?.mode === "primary" ? "Primary webhook ingestion. IMAP remains active as an idempotent safety net." : summary?.mode === "canary" ? "Canary ingestion health. Only enabled repositories may create jobs." : "Shadow ingestion health. Deliveries are observed but do not create jobs."} action={<RefreshButton onClick={onRefresh} />} />
{error ? <Banner tone="error" text={error.message} /> : null}
<div className="flex max-w-full flex-wrap rounded-md border border-border bg-white p-1" role="tablist" aria-label="Webhook dashboard section">
{sections.map((item) => <button key={item.id} type="button" role="tab" aria-label={item.count === undefined ? item.label : `${item.label} (${item.count} total)`} aria-selected={section === item.id} className={cn("inline-flex h-8 items-center gap-2 rounded px-3 text-sm font-semibold", section === item.id ? "bg-primary text-white" : "text-muted hover:bg-slate-50 hover:text-foreground")} onClick={() => onSectionChange(item.id)}>{item.label}{item.count !== undefined ? <span className="rounded border border-current/30 px-1 font-mono text-[10px]">{item.count}</span> : null}</button>)}
Expand Down
21 changes: 21 additions & 0 deletions docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,26 @@ row makes the queue operation idempotent, and the retry repairs the monitoring
receipt. `edited` comments/reviews, unsupported families, and repositories
outside `enabledRepos` remain observational only.

## Primary webhook with stable IMAP fallback

After the dashboard gate has no unexplained IMAP-only actionable events, set
both `GITHUB_AGENT_BRIDGE_WEBHOOK_MODE=primary` and
`GITHUB_AGENT_BRIDGE_WEBHOOK_PRIMARY_ACK=true`. The second switch is a deliberate
operator acknowledgement; `primary` fails closed without it. The configured
policy remains authoritative for trust, action and routing decisions.

Primary changes which source is expected to win, not the idempotency model.
Keep the IMAP reader enabled during the stable fallback phase. If webhook
delivery is late or unavailable, email still creates the canonical event and
job; if both arrive, the unique event key links the second receipt to the first
job. Rollback is configuration-only: return the endpoint to `shadow`, leave the
IMAP reader running, and inspect the exception queue before trying primary
again. Do not enable `--mark-seen` merely because primary mode is active.

This implementation does not add an arbitrary sleep to IMAP. Delaying the
reader would also delay genuine webhook gaps and complicate its durable UID
cursor. The first-source metrics make the actual winner visible, while the
shared transaction guarantees correctness independently of arrival order.

Webhook enqueueing must not be enabled until recovery of persisted-but-
unprocessed receipts and divergence metrics have been validated in production.
8 changes: 4 additions & 4 deletions docs/webhook-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ planning a new integration. Last reviewed: 2026-10-02.

Support levels:

- **Canonical shadow**: persisted with an immutable canonical event key and
eligible for IMAP/webhook coverage comparison. It never creates a job in
Phase 1.
- **Canonical**: persisted with an immutable canonical event key and eligible
for IMAP/webhook coverage comparison. It is observational in shadow mode and
may enqueue in canary/primary mode when policy permits it.
- **Observed only**: a signed delivery can be persisted as `unsupported`, but
no canonical key is derived.
- **Not selected**: operators should not subscribe to it in Phase 1.

| Support | GitHub event names |
| --- | --- |
| Canonical shadow | `commit_comment`, `issue_comment`, `pull_request_review`, `pull_request_review_comment`, `workflow_run` |
| Canonical | `commit_comment`, `issue_comment`, `pull_request_review`, `pull_request_review_comment`, `workflow_run` |
| Observed only / not selected | `branch_protection_configuration`, `branch_protection_rule`, `check_run`, `check_suite`, `code_scanning_alert`, `create`, `custom_property`, `custom_property_values`, `delete`, `dependabot_alert`, `deploy_key`, `deployment`, `deployment_protection_rule`, `deployment_review`, `deployment_status`, `discussion`, `discussion_comment`, `fork`, `github_app_authorization`, `gollum`, `installation`, `installation_repositories`, `installation_target`, `issue_dependencies`, `issue_relates_to`, `issues`, `label`, `marketplace_purchase`, `member`, `membership`, `merge_group`, `meta`, `milestone`, `org_block`, `organization`, `package`, `page_build`, `personal_access_token_request`, `ping`, `project`, `project_card`, `project_column`, `projects_v2`, `projects_v2_item`, `projects_v2_status_update`, `public`, `pull_request`, `pull_request_review_thread`, `push`, `registry_package`, `release`, `repository`, `repository_advisory`, `repository_dispatch`, `repository_import`, `repository_ruleset`, `repository_vulnerability_alert`, `secret_scanning_alert`, `secret_scanning_alert_location`, `secret_scanning_scan`, `security_advisory`, `security_and_analysis`, `sponsorship`, `star`, `status`, `sub_issues`, `team`, `team_add`, `watch`, `workflow_dispatch`, `workflow_job` |

The inventory names event families, not every `action` value. Actions are
Expand Down
20 changes: 14 additions & 6 deletions src/github_agent_bridge/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ def __init__(
webhook_retention_days: int | None = None,
webhook_mode: str | None = None,
webhook_policy: str | Path | None = None,
webhook_primary_ack: bool | None = None,
) -> None:
self.db = Path(db).expanduser()
self.secret_key = secret_key or os.getenv("GITHUB_AGENT_BRIDGE_DASHBOARD_SECRET_KEY", "")
Expand All @@ -263,12 +264,19 @@ def __init__(
self.webhook_max_bytes = webhook_max_bytes or int(os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_MAX_BYTES", "1048576"))
self.webhook_retention_days = webhook_retention_days or int(os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_RETENTION_DAYS", "30"))
self.webhook_mode = (webhook_mode or os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_MODE", "shadow")).lower()
if self.webhook_mode not in {"shadow", "canary"}:
raise ValueError("GITHUB_AGENT_BRIDGE_WEBHOOK_MODE must be shadow or canary")
if self.webhook_mode not in {"shadow", "canary", "primary"}:
raise ValueError("GITHUB_AGENT_BRIDGE_WEBHOOK_MODE must be shadow, canary, or primary")
policy_value = webhook_policy or os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_POLICY", "")
self.webhook_policy = Path(policy_value).expanduser() if policy_value else None
if self.webhook_mode == "canary" and self.webhook_policy is None:
raise ValueError("GITHUB_AGENT_BRIDGE_WEBHOOK_POLICY is required in canary mode")
if self.webhook_mode != "shadow" and self.webhook_policy is None:
raise ValueError("GITHUB_AGENT_BRIDGE_WEBHOOK_POLICY is required outside shadow mode")
self.webhook_primary_ack = (
webhook_primary_ack
if webhook_primary_ack is not None
else os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_PRIMARY_ACK", "").lower() in {"1", "true", "yes"}
)
if self.webhook_mode == "primary" and not self.webhook_primary_ack:
raise ValueError("primary webhook mode requires GITHUB_AGENT_BRIDGE_WEBHOOK_PRIMARY_ACK=true")

@property
def oauth_ready(self) -> bool:
Expand Down Expand Up @@ -778,13 +786,13 @@ async def github_webhook_shadow(request: Request) -> dict[str, Any]:
ensure_webhook_schema()
enqueue_status = None
job_id = None
if config.webhook_mode == "canary":
if config.webhook_mode in {"canary", "primary"}:
policy = Policy.from_file(config.webhook_policy)
notification = webhook_notification(event_name, delivery_id, payload)
repo = str(full_name or "").lower()
if notification is None:
enqueue_status = "ignored"
elif repo not in policy.enabled_repos:
elif config.webhook_mode == "canary" and repo not in policy.enabled_repos:
enqueue_status = "outside_canary"
else:
job, enqueue_status = JobQueue(config.db).ingest(
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/github_agent_bridge/dashboard_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Agent Bridge Dashboard</title>
<script type="module" crossorigin src="/assets/index-DbrBVlog.js"></script>
<script type="module" crossorigin src="/assets/index-DHTaiUxP.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-SqBiqy9C.js">
<link rel="stylesheet" crossorigin href="/assets/index-BDe9F9HC.css">
</head>
Expand Down
2 changes: 2 additions & 0 deletions src/github_agent_bridge/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def webhook_notification(
if not url.startswith("https://github.com/"):
return None
body = str(source.get("body") or "")
if event_name == "workflow_run":
body = f"Workflow run {source.get('conclusion') or source.get('status') or action}.\n{body}"
sender = payload.get("sender") if isinstance(payload.get("sender"), dict) else {}
login = str(sender.get("login") or "GitHub")
title = str(subject.get("title") or subject.get("name") or event_name)
Expand Down
3 changes: 3 additions & 0 deletions systemd/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,6 @@ GITHUB_AGENT_BRIDGE_WEB_PUSH_ICON_URL=
# the same policy used by the reader and only enqueues policy.enabledRepos.
GITHUB_AGENT_BRIDGE_WEBHOOK_MODE=shadow
GITHUB_AGENT_BRIDGE_WEBHOOK_POLICY=%h/.config/github-agent-bridge/policy.json
# Required in addition to WEBHOOK_MODE=primary. This prevents a config typo from
# changing the source of work without an explicit operator decision.
GITHUB_AGENT_BRIDGE_WEBHOOK_PRIMARY_ACK=
35 changes: 35 additions & 0 deletions tests/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,41 @@ def test_webhook_canary_ignores_repo_outside_enabled_repos(tmp_path):
assert con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 0


def test_webhook_primary_requires_explicit_acknowledgement(tmp_path):
try:
DashboardConfig(
db=tmp_path / "bridge.sqlite3", webhook_mode="primary",
webhook_policy=canary_policy(tmp_path), webhook_primary_ack=False,
)
except ValueError as exc:
assert "PRIMARY_ACK" in str(exc)
else:
raise AssertionError("primary mode must require an explicit acknowledgement")


def test_webhook_primary_enqueues_trusted_repo_without_canary_allowlist(tmp_path):
policy = canary_policy(tmp_path)
policy.write_text(json.dumps({
"trustedOrgs": ["gisce"], "enabledRepos": [], "botLogins": ["giscebot"],
"actions": {"trustedAuto": ["reply_comment"]},
}))
payload = actionable_issue_comment_payload()
config = DashboardConfig(
db=tmp_path / "bridge.sqlite3", require_auth=False,
webhook_secrets=(SECRET,), webhook_mode="primary", webhook_policy=policy,
webhook_primary_ack=True,
)

response = TestClient(create_app(config)).post(
"/api/webhooks/github", content=payload, headers=signed_headers(payload),
)

assert response.json()["mode"] == "primary"
assert response.json()["enqueue_status"] == "enqueued"
with sqlite3.connect(config.db) as con:
assert con.execute("SELECT first_source FROM github_events").fetchone()[0] == "webhook"


def test_webhook_shadow_selects_secret_by_repository_owner(tmp_path):
payload = issue_comment_payload()
client = TestClient(create_app(DashboardConfig(
Expand Down
Loading