Skip to content
Merged
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
19 changes: 19 additions & 0 deletions dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
webhookQuerySelection,
webhookTimeseriesPath,
selectedWebhookHookIdFromPath,
selectedWebhookDeliveryIdFromPath,
WebhookDeliveryDetailPage,
} from "./main";

describe("dashboard routing and API query helpers", () => {
Expand Down Expand Up @@ -115,6 +117,12 @@ describe("dashboard routing and API query helpers", () => {
expect(selectedWebhookHookIdFromPath("/webhooks")).toBeNull();
});

it("recognizes shareable webhook delivery detail routes", () => {
expect(isWebhooksPath("/webhooks/deliveries/abc%2F123")).toBe(true);
expect(selectedWebhookDeliveryIdFromPath("/webhooks/deliveries/abc%2F123")).toBe("abc/123");
expect(selectedWebhookDeliveryIdFromPath("/webhooks/hooks/42")).toBeNull();
});

it("shows a knowledge badge when proposed rules need moderation", () => {
const onNavigate = vi.fn();
const { rerender } = render(<SectionNav isDashboardRoute={true} isSystemRoute={false} isKnowledgeRoute={false} isMcpRoute={false} knowledgeBadgeCount={2} systemUpdateAvailable />);
Expand Down Expand Up @@ -196,6 +204,17 @@ describe("dashboard routing and API query helpers", () => {
expect(screen.getAllByText("delivery-1").length).toBeGreaterThan(0);
});

it("shows the full webhook payload and linked job status", async () => {
const onViewJob = vi.fn();
const user = userEvent.setup();
render(<WebhookDeliveryDetailPage data={{ delivery: { delivery_id: "delivery-1", created_at: "2026-10-02T11:08:00Z", hook_id: "42", event_name: "issue_comment", action: "created", event_key: "issue_comment:created:gisce/github-agent-bridge:7", repository: "gisce/github-agent-bridge", status: "observed" }, payload_hash: "abc123", payload: { action: "created", comment: { id: 7, body: "@giscebot fix it" } }, job: { id: 91, work_key: "gisce/github-agent-bridge#191", status: "running", action: "reply_comment", decision: "auto_trusted", work_intent: "work_allowed", updated_at: "2026-10-02T11:09:00Z" } }} loading={false} error={null} onBack={vi.fn()} onRefresh={vi.fn()} onViewHook={vi.fn()} onViewJob={onViewJob} />);

expect(screen.getByText(/"body": "@giscebot fix it"/)).toBeInTheDocument();
expect(screen.getByText("Job #91 · running · reply_comment · work_allowed")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open job" }));
expect(onViewJob).toHaveBeenCalledWith(91);
});

it("loads the next hook cursor page when the inventory sentinel enters view", async () => {
const onLoadMore = vi.fn();
class ImmediateIntersectionObserver {
Expand Down
71 changes: 62 additions & 9 deletions dashboard/src/main.tsx

Large diffs are not rendered by default.

20 changes: 13 additions & 7 deletions docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ Multiple organizations and multiple hooks may use the same endpoint when each
owner has its own entry in `GITHUB_AGENT_BRIDGE_WEBHOOK_SECRETS_BY_OWNER`.
GitHub's `X-GitHub-Hook-ID` header keeps their inventory and activity separate.

The endpoint stores only routing metadata, a SHA-256 payload hash, and the
canonical event key in `webhook_shadow_receipts`; it deliberately stores no raw
payload and never creates a queue job. The monitoring API is split so opening
the overview does not load hook inventory or delivery history:
The endpoint stores routing metadata, a SHA-256 payload hash, the canonical
event key, and the verified JSON payload in `webhook_shadow_receipts`. The
payload follows the same bounded retention as its receipt and is exposed only
through the administrator-only delivery detail endpoint. The monitoring API is
split so opening the overview does not load hook inventory or delivery history:

- `GET /api/webhooks/github/summary` returns mode, receipt counts, retries, and
cross-source coverage (`both`, `imap_only`, `webhook_only`) with an explicit
Expand All @@ -108,18 +109,23 @@ the overview does not load hook inventory or delivery history:
cursor-paginated delivery page. Optional `hook_id`, `event_name`, `repository`,
and `result` filters are applied server-side; every row includes the known
hook identity so operators can navigate directly to its detail.
- `GET /api/webhooks/github/deliveries/{delivery_id}` returns the retained full
JSON payload, its SHA-256 hash, delivery metadata, and the linked job summary
when ingestion created or coalesced into a job. Legacy receipts created before
payload retention return `payload: null`.

The dashboard loads these resources lazily per tab, caches them separately, and
appends cursor pages as the inventory or delivery list scrolls. Hook detail URLs
are shareable under `/webhooks/hooks/{hook_id}` and link to GitHub's webhook
appends cursor pages as the inventory or delivery list scrolls. Hook and
delivery detail URLs are shareable under `/webhooks/hooks/{hook_id}` and
`/webhooks/deliveries/{delivery_id}`; hook detail links to GitHub's webhook
settings when the target is known.
Here “operators” means users authorized as dashboard administrators through
`GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_USERS` or
`GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_TEAMS`; every monitoring endpoint returns
HTTP 403 to other authenticated users and HTTP 401 to unauthenticated users.
Receipt details are retained for 30 days by default and pruned during ingestion;
set `GITHUB_AGENT_BRIDGE_WEBHOOK_RETENTION_DAYS` to change that window. Raw
payloads are never retained.
payloads are pruned with their receipts.

The maintained event inventory and support levels are in
[`webhook-events.md`](webhook-events.md).
Expand Down
37 changes: 37 additions & 0 deletions src/github_agent_bridge/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,43 @@ def github_webhook_shadow_deliveries(
"next_cursor": next_cursor,
}

@app.get("/api/webhooks/github/deliveries/{delivery_id}")
def github_webhook_delivery_detail(
delivery_id: str,
_: dict[str, Any] = Depends(current_admin_profile),
) -> dict[str, Any]:
ensure_webhook_schema()
with sqlite3.connect(config.db) as con:
con.row_factory = sqlite3.Row
row = con.execute(
"SELECT r.delivery_id,r.hook_id,r.event_name,r.action,r.event_key,r.repository,r.status,"
"r.enqueue_status,r.duplicate_count,r.created_at,r.payload_hash,r.payload_json,"
"h.target hook_target,h.target_type hook_target_type,"
"j.id job_id,j.work_key job_work_key,j.status job_status,j.action job_action,"
"j.decision job_decision,j.work_intent job_work_intent,j.updated_at job_updated_at "
"FROM webhook_shadow_receipts r "
"LEFT JOIN webhook_hooks h ON h.hook_id=r.hook_id "
"LEFT JOIN ingest_receipts i ON i.source='webhook' AND i.source_key=r.delivery_id "
"LEFT JOIN jobs j ON j.id=COALESCE(r.job_id,i.job_id) WHERE r.delivery_id=?",
(delivery_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="webhook_delivery_not_found")
payload = json.loads(row["payload_json"]) if row["payload_json"] else None
job = None
if row["job_id"] is not None:
job = {
"id": row["job_id"], "work_key": row["job_work_key"], "status": row["job_status"],
"action": row["job_action"], "decision": row["job_decision"],
"work_intent": row["job_work_intent"], "updated_at": row["job_updated_at"],
}
return {
"delivery": _webhook_delivery_payload(row),
"payload_hash": row["payload_hash"],
"payload": payload,
"job": job,
}

def dashboard_index() -> FileResponse:
index = config.static_dir / "index.html"
if not index.exists():
Expand Down
187 changes: 187 additions & 0 deletions src/github_agent_bridge/dashboard_static/assets/index-CHm1EEiu.js

Large diffs are not rendered by default.

187 changes: 0 additions & 187 deletions src/github_agent_bridge/dashboard_static/assets/index-wDewYnHF.js

This file was deleted.

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-wDewYnHF.js"></script>
<script type="module" crossorigin src="/assets/index-CHm1EEiu.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-SqBiqy9C.js">
<link rel="stylesheet" crossorigin href="/assets/index-BjEIhlw4.css">
</head>
Expand Down
1 change: 1 addition & 0 deletions src/github_agent_bridge/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,7 @@ def _ensure_columns(self, con: sqlite3.Connection) -> None:
"webhook_shadow_receipts": {
"duplicate_count": "INTEGER NOT NULL DEFAULT 0",
"hook_id": "TEXT",
"payload_json": "TEXT",
"enqueue_status": "TEXT",
"job_id": "INTEGER REFERENCES jobs(id) ON DELETE SET NULL",
},
Expand Down
1 change: 1 addition & 0 deletions src/github_agent_bridge/sql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS webhook_shadow_receipts (
event_key TEXT,
repository TEXT,
payload_hash TEXT NOT NULL,
payload_json TEXT,
status TEXT NOT NULL CHECK(status IN ('observed','duplicate','unsupported')),
enqueue_status TEXT,
job_id INTEGER REFERENCES jobs(id) ON DELETE SET NULL,
Expand Down
5 changes: 3 additions & 2 deletions src/github_agent_bridge/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,11 @@ def persist_shadow_delivery(
)
try:
con.execute(
"INSERT INTO webhook_shadow_receipts(delivery_id,hook_id,event_name,action,event_key,repository,payload_hash,status,enqueue_status,job_id,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
"INSERT INTO webhook_shadow_receipts(delivery_id,hook_id,event_name,action,event_key,repository,payload_hash,payload_json,status,enqueue_status,job_id,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(
delivery_id, hook_id, event_name, action, event_key, repo,
payload_hash, status, enqueue_status, job_id, now,
payload_hash, raw_payload.decode("utf-8"), status,
enqueue_status, job_id, now,
),
)
except sqlite3.IntegrityError:
Expand Down
26 changes: 26 additions & 0 deletions tests/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,21 @@ def test_webhook_canary_enqueues_enabled_actionable_repository_once(tmp_path):
assert first.json()["enqueue_status"] == "enqueued"
assert first.json()["job_id"]
assert retry.json()["enqueue_status"] == "duplicate"
detail = client.get("/api/webhooks/github/deliveries/delivery-1").json()
assert detail["delivery"]["delivery_id"] == "delivery-1"
assert detail["delivery"]["enqueue_status"] == "enqueued"
assert detail["delivery"]["job_id"] == first.json()["job_id"]
assert detail["payload"] == json.loads(payload)
assert detail["payload_hash"] == hashlib.sha256(payload).hexdigest()
assert detail["job"] == {
"id": first.json()["job_id"],
"work_key": "gisce/github-agent-bridge#191",
"status": "pending",
"action": "reply_comment",
"decision": "auto_trusted",
"work_intent": "work_allowed",
"updated_at": detail["job"]["updated_at"],
}
with sqlite3.connect(config.db) as con:
assert con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 1
assert con.execute(
Expand Down Expand Up @@ -752,12 +767,15 @@ def test_webhook_status_requires_dashboard_admin(tmp_path):
)
assert {client.get(path).status_code for path in paths} == {401}
assert client.get("/api/webhooks/github/hooks/42").status_code == 401
assert client.get("/api/webhooks/github/deliveries/delivery-1").status_code == 401
client.cookies.set("gab_dashboard_session", _sign(config, _encode_session({"login": "alice"})))
assert {client.get(path).status_code for path in paths} == {403}
assert client.get("/api/webhooks/github/hooks/42").status_code == 403
assert client.get("/api/webhooks/github/deliveries/delivery-1").status_code == 403
client.cookies.set("gab_dashboard_session", _sign(config, _encode_session({"login": "operator"}, is_admin=True)))
assert {client.get(path).status_code for path in paths} == {200}
assert client.get("/api/webhooks/github/hooks/42").status_code == 404
assert client.get("/api/webhooks/github/deliveries/delivery-1").status_code == 404
assert client.get("/api/status").json()["webhook_configured"] is True


Expand Down Expand Up @@ -864,6 +882,14 @@ def test_webhook_monitoring_endpoints_keep_summary_light_and_return_real_data(tm
}
assert second_page["next_cursor"] is None

delivery_detail = client.get("/api/webhooks/github/deliveries/delivery-1").json()
assert delivery_detail["delivery"] == next(
item for item in deliveries if item["delivery_id"] == "delivery-1"
)
assert delivery_detail["payload"] == json.loads(delivery)
assert delivery_detail["payload_hash"] == hashlib.sha256(delivery).hexdigest()
assert delivery_detail["job"] is None

filtered = client.get("/api/webhooks/github/deliveries", params={
"hook_id": "42", "event_name": "issue_comment", "repository": "gisce/github-agent-bridge",
"result": "observed",
Expand Down
Loading