Slice 8 WIP: §12 hygiene + §10.7 + routing + rollback cleanup

- Add §12 30/90 hygiene scheduler in hygiene.py, mirroring the
  DigestScheduler shape; wires next to digest in main.py with the
  same start/stop/run_tick test seam.
- Extend bot.delete_branch to accept actor=None for system gestures,
  per §15.9 (actor_user_id=NULL, on_behalf_of=bot_login).
- Convert every branches/{branch} route in api_branches.py and
  api_prs.py to {branch:path}; move the bare GET to the bottom of
  the router so deeper GETs match before greedy-path swallow.
- Extend api_prs.py's _require_pr to accept pr_kind='meta_metadata'
  so the §9.5 metadata-pane PRs land an in-app merge.
- Graduation rollback now deletes the graduate-<slug>-<6hex> branch
  after closing the PR — §19.2 candidate that lands here.
- Email-bounce webhook gains a WEBHOOK_EMAIL_BOUNCE_SECRET seam.
- FakeGitea grows a DELETE /branches/{branch:path} handler and a
  slashed-branch read; integration tests for the hygiene vertical
  cover the 30d close, 90d delete, post-merge delete, pinned
  exemption, per-user cursor preservation, no-notification rule,
  and the graduation-rollback cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 04:03:09 -07:00
parent 060fa408a2
commit 1a0c4428af
9 changed files with 965 additions and 105 deletions
+20 -4
View File
@@ -390,11 +390,27 @@ def make_router(config: Config) -> APIRouter:
)
@router.post("/api/webhooks/email-bounce")
async def email_bounce(body: BounceBody) -> dict[str, Any]:
async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
# §15.4: hard bounces and complaints flip the global opt-out.
# The webhook is unauthenticated here for v1 — the SMTP provider's
# callback URL is the contract. Tighten with a signing secret
# when an actual provider is wired in.
# Per the §19.2 "email bounce webhook authentication" candidate
# Slice 8 settles: when `WEBHOOK_EMAIL_BOUNCE_SECRET` is set in
# env, the webhook requires the same value in the
# `X-Webhook-Secret` header. The shared-secret shape is the
# narrowest seam that covers the major providers — Sendgrid's
# `X-Twilio-Email-Event-Webhook-Signature`, SES via SNS topic
# signatures, Postmark's HTTP basic auth — without forcing a
# per-provider verifier today. When the operator wires a real
# SMTP provider they pick the equivalent shared-secret or
# rotate the value behind whichever signature scheme the
# provider supports. When the env var is unset the webhook
# stays unauthenticated for dev (the v1 contract).
import os as _os
expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
if expected:
received = request.headers.get("X-Webhook-Secret", "")
import hmac as _hmac
if not received or not _hmac.compare_digest(expected, received):
raise HTTPException(401, "Invalid webhook signature")
row = db.conn().execute(
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
).fetchone()