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:
+109
-91
@@ -258,83 +258,15 @@ def make_router(
|
||||
"pre_graduation_history": pre_grad,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §17: GET /api/rfcs/<slug>/branches/<branch>
|
||||
# Per §4: branch bodies are NOT cached — fetch live from Gitea.
|
||||
# Per §9.5 / §17: when slug resolves to super-draft, <branch> names
|
||||
# a meta-repo branch and the underlying file is rfcs/<slug>.md with
|
||||
# the body wrapped in frontmatter.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch}")
|
||||
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
result = await gitea.read_file(owner, repo, path, ref=branch)
|
||||
if result is None:
|
||||
br = await gitea.get_branch(owner, repo, branch)
|
||||
if br is None:
|
||||
raise HTTPException(404, "Branch not found")
|
||||
body, body_sha = "", ""
|
||||
else:
|
||||
content, body_sha = result
|
||||
body = _extract_body(rfc, content, branch)
|
||||
|
||||
# Ensure the whole-doc chat thread for the branch exists.
|
||||
thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
|
||||
|
||||
# Sub-threads (range/paragraph) and flags scoped to this branch.
|
||||
thread_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, anchor_kind, anchor_payload, thread_kind, label, state, created_by, created_at
|
||||
FROM threads
|
||||
WHERE rfc_slug = ? AND branch_name = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(slug, branch),
|
||||
).fetchall()
|
||||
threads = [_serialize_thread(t) for t in thread_rows]
|
||||
|
||||
# Visibility, contribute, grants.
|
||||
vis = _branch_vis(slug, branch)
|
||||
grants = _branch_grants(slug, branch)
|
||||
|
||||
# Pending and resolved changes scoped to this branch.
|
||||
changes_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, thread_id, source_message_id, kind, state, original, proposed, reason,
|
||||
was_edited_before_accept, stale_since, acted_by, acted_at, commit_sha, created_at
|
||||
FROM changes
|
||||
WHERE rfc_slug = ? AND branch_name = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(slug, branch),
|
||||
).fetchall()
|
||||
changes = [_serialize_change(c) for c in changes_rows]
|
||||
|
||||
# Branch metadata for the breadcrumb / header.
|
||||
creator = _branch_creator(slug, branch)
|
||||
capabilities = _capabilities(rfc, slug, branch, viewer, creator)
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": rfc["title"],
|
||||
"branch_name": branch,
|
||||
"body": body,
|
||||
"body_sha": body_sha,
|
||||
"main_thread_id": thread_id,
|
||||
"threads": threads,
|
||||
"changes": changes,
|
||||
"visibility": vis,
|
||||
"grants": grants,
|
||||
"creator": creator,
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
# The bare `GET /api/rfcs/<slug>/branches/<branch>` is declared
|
||||
# at the *bottom* of this router so the more-specific deeper GET
|
||||
# routes — `branches/{branch:path}/threads` and
|
||||
# `branches/{branch:path}/threads/{thread_id}/messages` — match
|
||||
# before the bare GET swallows a sub-route path with `:path`'s
|
||||
# greedy match. Per the §19.2 "branch-name path routing" candidate
|
||||
# Slice 8 settles: ordering discipline against `{branch:path}` is
|
||||
# how the slashed-branch read works without collisions. See
|
||||
# `get_branch_view` below `stream_chat_turn`.
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §17: POST /api/rfcs/<slug>/branches/main/promote-to-branch
|
||||
@@ -506,7 +438,7 @@ def make_router(
|
||||
# §17 / §8.9: accept / decline / reask a change
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/accept")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/accept")
|
||||
async def accept_change(
|
||||
slug: str,
|
||||
branch: str,
|
||||
@@ -599,7 +531,7 @@ def make_router(
|
||||
|
||||
return {"ok": True, "commit_sha": sha, "change_id": change_id}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/decline")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/decline")
|
||||
async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
@@ -617,7 +549,7 @@ def make_router(
|
||||
)
|
||||
return {"ok": True, "change_id": change_id}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/reask")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/reask")
|
||||
async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
@@ -674,7 +606,7 @@ def make_router(
|
||||
# §17 / §8.11 / §10.6: manual-edit flush
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/manual-flush")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/manual-flush")
|
||||
async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
@@ -751,7 +683,7 @@ def make_router(
|
||||
# §17 / §11: visibility + contribute + grants
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/visibility")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/visibility")
|
||||
async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
@@ -772,7 +704,7 @@ def make_router(
|
||||
)
|
||||
return {"ok": True, "visibility": _branch_vis(slug, branch)}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/grants")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/grants")
|
||||
async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
@@ -793,7 +725,7 @@ def make_router(
|
||||
)
|
||||
return {"ok": True, "grants": _branch_grants(slug, branch)}
|
||||
|
||||
@router.delete("/api/rfcs/{slug}/branches/{branch}/grants/{grantee_login}")
|
||||
@router.delete("/api/rfcs/{slug}/branches/{branch:path}/grants/{grantee_login}")
|
||||
async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
@@ -813,7 +745,7 @@ def make_router(
|
||||
# §17 / §8.12 / §8.13: threads
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch}/threads")
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads")
|
||||
async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
@@ -831,7 +763,7 @@ def make_router(
|
||||
).fetchall()
|
||||
return {"items": [_serialize_thread(r) for r in rows]}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/threads")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads")
|
||||
async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
@@ -861,7 +793,7 @@ def make_router(
|
||||
)
|
||||
return {"thread_id": thread_id, "message_id": message_id}
|
||||
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/messages")
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages")
|
||||
async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
@@ -884,7 +816,7 @@ def make_router(
|
||||
"messages": [_serialize_message(r) for r in rows],
|
||||
}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/messages")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages")
|
||||
async def post_thread_message(
|
||||
slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
@@ -901,7 +833,7 @@ def make_router(
|
||||
)
|
||||
return {"ok": True, "message_id": message_id}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/chat-seen")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/chat-seen")
|
||||
async def advance_chat_seen(slug: str, branch: str, body: dict, request: Request) -> dict[str, Any]:
|
||||
"""§15.7 chat-seen cursor advance.
|
||||
|
||||
@@ -930,7 +862,7 @@ def make_router(
|
||||
)
|
||||
return {"ok": True, "reconciled": reconciled}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/resolve")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/resolve")
|
||||
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
@@ -951,7 +883,7 @@ def make_router(
|
||||
# §17 / §18 carryover: SSE-streaming chat turn on a thread
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/chat")
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/chat")
|
||||
async def stream_chat_turn(
|
||||
slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request
|
||||
):
|
||||
@@ -1015,6 +947,92 @@ def make_router(
|
||||
}
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §17: GET /api/rfcs/<slug>/branches/<branch>
|
||||
# Per §4: branch bodies are NOT cached — fetch live from Gitea.
|
||||
# Per §9.5 / §17: when slug resolves to super-draft, <branch> names
|
||||
# a meta-repo branch and the underlying file is rfcs/<slug>.md with
|
||||
# the body wrapped in frontmatter.
|
||||
#
|
||||
# Declared LAST among the branch-scoped GET routes per the §19.2
|
||||
# "branch-name path routing" candidate: `{branch:path}` is greedy
|
||||
# by Starlette's converter, so `branches/foo/threads` would match
|
||||
# this bare GET with branch=foo/threads if declared first. Putting
|
||||
# the more-specific `threads` and `threads/{thread_id}/messages`
|
||||
# GETs above lets them claim those URLs; this one catches anything
|
||||
# else, including slashed branch names like `foo/bar`.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
|
||||
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
result = await gitea.read_file(owner, repo, path, ref=branch)
|
||||
if result is None:
|
||||
br = await gitea.get_branch(owner, repo, branch)
|
||||
if br is None:
|
||||
raise HTTPException(404, "Branch not found")
|
||||
body, body_sha = "", ""
|
||||
else:
|
||||
content, body_sha = result
|
||||
body = _extract_body(rfc, content, branch)
|
||||
|
||||
# Ensure the whole-doc chat thread for the branch exists.
|
||||
thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
|
||||
|
||||
# Sub-threads (range/paragraph) and flags scoped to this branch.
|
||||
thread_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, anchor_kind, anchor_payload, thread_kind, label, state, created_by, created_at
|
||||
FROM threads
|
||||
WHERE rfc_slug = ? AND branch_name = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(slug, branch),
|
||||
).fetchall()
|
||||
threads = [_serialize_thread(t) for t in thread_rows]
|
||||
|
||||
# Visibility, contribute, grants.
|
||||
vis = _branch_vis(slug, branch)
|
||||
grants = _branch_grants(slug, branch)
|
||||
|
||||
# Pending and resolved changes scoped to this branch.
|
||||
changes_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, thread_id, source_message_id, kind, state, original, proposed, reason,
|
||||
was_edited_before_accept, stale_since, acted_by, acted_at, commit_sha, created_at
|
||||
FROM changes
|
||||
WHERE rfc_slug = ? AND branch_name = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(slug, branch),
|
||||
).fetchall()
|
||||
changes = [_serialize_change(c) for c in changes_rows]
|
||||
|
||||
# Branch metadata for the breadcrumb / header.
|
||||
creator = _branch_creator(slug, branch)
|
||||
capabilities = _capabilities(rfc, slug, branch, viewer, creator)
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": rfc["title"],
|
||||
"branch_name": branch,
|
||||
"body": body,
|
||||
"body_sha": body_sha,
|
||||
"main_thread_id": thread_id,
|
||||
"threads": threads,
|
||||
"changes": changes,
|
||||
"visibility": vis,
|
||||
"grants": grants,
|
||||
"creator": creator,
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Permission + state helpers (closures, share `config` etc.)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user