From 89b07f78576018c4f211578e1a3b5c96f914d2d6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 13:05:00 -0400 Subject: [PATCH] feat(forge): push webhook flags drift at the moment the repo moves (#2691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second adapter consumer. POST /api/webhooks/forge validates Gitea's X-Gitea-Signature (HMAC-SHA256, constant-time; no secret configured = the endpoint 404s out of existence), extracts changed/removed paths, and flags matched snippets by writing verification.invalidated_by {commit_sha, at, path, removed} — the existing attention vocabulary extended, not a new flag: needs_attention includes it, both filter dialects (Python + jsonpath SQL) include it in 'attention' and exclude it from 'ok', and recording ANY fresh verdict clears it by construction because compose_verification builds a new dict. Unverified snippets are skipped (already in their own bucket); replayed deliveries at the same head commit are no-ops; processing failures return 200 with a WARNING + AppLog canary so the forge never marks deliveries failed and operators never disable the hook over a transient (#2663's lesson). Matching goes through repo BINDINGS: recorded location repos are free-form names ('Scribe') that cannot address a forge, so a snippet reaches its forge repo through its project's binding — which also fixes step 5's pull-time resolution for every real record via the same fallback. O(bindings + snippets-in-project + changed files). Settings: webhook secret beside the forge config (masked, sentinel- skipped, Docker-secret env channel, endpoint documented in the UI). Tests: signature gate, payload parsing, path semantics, both filter dialects extended in the drift-check guard file, and real-Postgres end-to-end (flag lands, attention lists it, replay quiet, re-verify clears, unbound repo untouched). Co-Authored-By: Claude Fable 5 --- frontend/src/views/SettingsView.vue | 18 ++- src/scribe/app.py | 2 + src/scribe/config.py | 3 + src/scribe/routes/admin.py | 17 ++- src/scribe/routes/settings.py | 2 +- src/scribe/routes/webhooks.py | 107 ++++++++++++++ src/scribe/services/knowledge.py | 21 ++- src/scribe/services/repo_bindings.py | 35 +++++ src/scribe/services/snippets.py | 124 +++++++++++++++- tests/test_forge_webhook.py | 210 +++++++++++++++++++++++++++ tests/test_snippet_drift_check.py | 32 ++++ 11 files changed, 559 insertions(+), 12 deletions(-) create mode 100644 src/scribe/routes/webhooks.py create mode 100644 tests/test_forge_webhook.py diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 2aaa58b..f749eee 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -422,7 +422,7 @@ const baseUrlSaved = ref(false); // Git forge integration (admin only, #2689). The token round-trips masked; // the server treats the mask as "unchanged". -const forge = ref({ kind: "", base_url: "", token: "" }); +const forge = ref({ kind: "", base_url: "", token: "", webhook_secret: "" }); const forgeKinds = ref(["gitea"]); const forgeConfigured = ref(false); const savingForge = ref(false); @@ -586,10 +586,13 @@ onMounted(async () => { async function loadForgeSettings() { const cfg = await apiGet<{ - kind: string; base_url: string; token: string; + kind: string; base_url: string; token: string; webhook_secret: string; configured: boolean; kinds: string[]; }>("/api/admin/forge"); - forge.value = { kind: cfg.kind, base_url: cfg.base_url, token: cfg.token }; + forge.value = { + kind: cfg.kind, base_url: cfg.base_url, token: cfg.token, + webhook_secret: cfg.webhook_secret, + }; forgeConfigured.value = cfg.configured; if (cfg.kinds?.length) forgeKinds.value = cfg.kinds; } @@ -2177,6 +2180,15 @@ function formatUserDate(iso: string): string { +
+ + +

+ Optional: create a push webhook on the forge pointing at + /api/webhooks/forge with this secret, and snippets + whose recorded files change get flagged for re-verification. +

+