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
+17 -4
View File
@@ -142,20 +142,25 @@ def verification_matches(data: dict | None, value: str) -> bool:
if not status:
return want == "unverified"
expired = verdict.get("code_sha") != (data or {}).get("code_sha")
# A push touched the recorded location since the verdict (#2691): the repo
# moved under it, so it needs a look even though it hasn't failed.
invalidated = bool(verdict.get("invalidated_by"))
if want == "unverified":
return False
if want == "drifted":
return status != "ok"
if want == "attention":
return status != "ok" or expired
return status != "ok" or expired or invalidated
if want == "ok":
return status == "ok" and not expired
return status == "ok" and not expired and not invalidated
return status == want
_VERIFY_DRIFTED_JSONPATH = '$.verification ? (@.status != "ok")'
_VERIFY_EXPIRED_JSONPATH = "$ ? (@.verification.code_sha != @.code_sha)"
_VERIFY_ANY_JSONPATH = "$.verification"
# A push touched the recorded location since the verdict (#2691).
_VERIFY_INVALIDATED_JSONPATH = "$.verification.invalidated_by"
def _verification_clause(value: str):
@@ -171,19 +176,23 @@ def _verification_clause(value: str):
if want == "drifted":
return Note.data.path_exists(_VERIFY_DRIFTED_JSONPATH)
if want == "attention":
# Everything worth looking at: a failing verdict, OR an expired one.
# Everything worth looking at: a failing verdict, an expired one, OR
# one whose recorded location a push has since touched (#2691).
return or_(
Note.data.path_exists(_VERIFY_DRIFTED_JSONPATH),
and_(has_verdict, Note.data.path_exists(_VERIFY_EXPIRED_JSONPATH)),
Note.data.path_exists(_VERIFY_INVALIDATED_JSONPATH),
)
if want == "ok":
# A clean bill of health that still describes the current code. The
# `~expired` half matters: without it this would quietly include records
# whose blessing has lapsed, which is the exact failure the feature is
# meant to catch.
# meant to catch. Same for push-invalidation — "ok" must mean the repo
# hasn't moved under the verdict either.
return and_(
Note.data.path_exists('$.verification ? (@.status == "ok")'),
~Note.data.path_exists(_VERIFY_EXPIRED_JSONPATH),
~Note.data.path_exists(_VERIFY_INVALIDATED_JSONPATH),
)
# A specific status: 'missing' | 'moved' | 'changed'.
return Note.data.path_exists(
@@ -229,6 +238,10 @@ def _note_to_item(note: Note) -> dict:
"detail": verdict.get("detail"),
"path": verdict.get("path"),
}
# Present only when a push has touched the recorded location since the
# verdict (#2691) — the "recheck me" marker, cleared by re-verifying.
if verdict.get("invalidated_by"):
item["verification"]["invalidated_by"] = verdict["invalidated_by"]
# Task fields — override note_type and add status/priority/due_date
if note.is_task: