feat(forge): push webhook flags drift at the moment the repo moves (#2691)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 39s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 13:05:00 -04:00
co-authored by Claude Fable 5
parent eb760eb440
commit 89b07f7857
11 changed files with 559 additions and 12 deletions
+15 -3
View File
@@ -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<string[]>(["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 {
<label for="forge-token">API Token (read scope)</label>
<input id="forge-token" v-model="forge.token" type="password" class="input" />
</div>
<div class="field">
<label for="forge-webhook-secret">Webhook Secret</label>
<input id="forge-webhook-secret" v-model="forge.webhook_secret" type="password" class="input" />
<p class="field-hint">
Optional: create a push webhook on the forge pointing at
<code>/api/webhooks/forge</code> with this secret, and snippets
whose recorded files change get flagged for re-verification.
</p>
</div>
</div>
<div class="actions" style="margin-bottom: 1.25rem;">
<button class="btn-primary" @click="saveForge" :disabled="savingForge">