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
+32
View File
@@ -170,6 +170,38 @@ def test_python_dialect_on_a_row_with_no_data_at_all():
assert knowledge_svc.verification_matches(None, "ok") is False
@pytest.mark.parametrize(
"value, expected",
[("ok", False), ("attention", True), ("drifted", False), ("unverified", False)],
)
def test_python_dialect_on_a_push_invalidated_ok_verdict(value, expected):
"""#2691: a push touched the recorded location since the verdict. Like the
expired case, it is neither drifted (nothing found wrong) nor unverified (a
check happened) — but the repo moved under the blessing, so `attention`
must include it and `ok` must not."""
data = _data("ok", "aaa", "aaa")
data["verification"]["invalidated_by"] = {"commit_sha": "d" * 40, "at": "t"}
assert knowledge_svc.verification_matches(data, value) is expected
def test_push_invalidation_reads_as_attention_and_clears_on_reverify():
"""The flag rides the verdict dict, so recording ANY fresh verdict clears
it by construction — compose_verification builds a new dict. No clearing
branch exists to forget."""
sha = code_sha("def f(): pass")
verdict = compose_verification(status="ok", checked_code_sha=sha)
verdict["invalidated_by"] = {"commit_sha": "d" * 40, "at": "t"}
fields = {"code": "def f(): pass", "verification": verdict}
view = verification_view(_note(), fields)
assert view["needs_attention"] is True
assert view["invalidated_by"]["commit_sha"] == "d" * 40
fresh = compose_verification(status="ok", checked_code_sha=sha)
assert "invalidated_by" not in fresh
view2 = verification_view(_note(), {"code": "def f(): pass", "verification": fresh})
assert view2["needs_attention"] is False
# --- the filter, SQL dialect ----------------------------------------------