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
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user