fix(§22/G-15): make the branch/edit/body subsystem three-tier aware (v0.53.0)
§22 migrated the READ/catalog path to the three-tier (project/collection)
model but the WRITE/branch/body subsystem still hardcoded the default
project's default collection — resolving every meta-resident entry to the
default content repo at rfcs/<slug>.md, ignoring the entry's project (its own
content repo) and collection (a <subfolder>/rfcs/ prefix). An entry outside
the default collection rendered a blank canonical body and every edit/PR/
body-write path hit the wrong file.
- New single resolver: projects.content_repo_for_collection() +
projects.entry_location(config, cid, slug) -> (org, repo, md_path)
(collection -> project -> content_repo + subfolder; falls back to the
default repo for a legacy/unknown collection).
- Every entry write/read path resolves via it: api_branches (body GET + all
branch write paths), api_prs (pr-draft/open/merge/withdraw/review/
resolution-branch), api_graduation (graduate/claim/retire/unretire +
orchestrator + state-flip), api.py mark-reviewed + idea-PR merge/decline/
withdraw + proposal preview, api_metadata. bot.open_metadata_pr and
bot.mark_entry_reviewed gained a file_path param. refresh_meta_branches,
the webhook corpus-refresh dispatch, and the hygiene branch-delete now
span every project's content repo, not just the default.
- New additive collection-scoped body-read routes:
GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main and
.../branches/{branch} disambiguate a slug across collections (G-5) and read
the entry's own repo. Slug-only routes kept (now collection-aware via the
cached row). Frontend getRFCMain/getBranch take optional pid+cid; RFCView
threads them (mirrors the v0.52.1 entry-detail fix).
No migration, no config change. Existing default-collection entries
unaffected. Tests: backend resolver + collection-scoped branch/body + graduate-
in-subfolder write path; frontend api unit. backend 677 / frontend 60 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -344,12 +344,14 @@ def make_router(
|
||||
# Dual-read the meta-repo entry once (§22.4a sidecar-aware) — we need its
|
||||
# git state for the graduation commit and the body to carry through
|
||||
# unchanged (meta-only keeps the body in the entry, §13.3).
|
||||
st = await metadata_mod.read_entry_from_git(
|
||||
gitea, config.gitea_org,
|
||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||
)
|
||||
# §22/G-15: resolve the repo+path from the entry's COLLECTION, not the
|
||||
# deployment default, so an entry in a non-default project graduates in
|
||||
# its own content repo / collection subfolder.
|
||||
org, meta_repo, md_path = projects_mod.entry_location(
|
||||
config, rfc["collection_id"], slug)
|
||||
st = await metadata_mod.read_entry_from_git(gitea, org, meta_repo, md_path)
|
||||
if st is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
raise HTTPException(409, f"Meta entry {md_path} not found on main")
|
||||
super_draft_entry = st.entry
|
||||
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
||||
@@ -378,7 +380,7 @@ def make_router(
|
||||
extra=dict(super_draft_entry.extra),
|
||||
)
|
||||
graduation_files = metadata_mod.write_entry_files(
|
||||
f"rfcs/{slug}.md", graduated_entry, st)
|
||||
md_path, graduated_entry, st)
|
||||
|
||||
state = _new_active(
|
||||
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
|
||||
@@ -398,6 +400,7 @@ def make_router(
|
||||
coro = _orchestrate(
|
||||
config=config, gitea=gitea, bot=bot,
|
||||
actor=viewer.as_actor(), state=state,
|
||||
meta_repo=meta_repo,
|
||||
graduation_files=graduation_files,
|
||||
)
|
||||
if request.query_params.get("_sync") == "1":
|
||||
@@ -478,21 +481,21 @@ def make_router(
|
||||
if already:
|
||||
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
||||
|
||||
st = await metadata_mod.read_entry_from_git(
|
||||
gitea, config.gitea_org,
|
||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||
)
|
||||
# §22/G-15: resolve repo+path from the entry's collection, not the default.
|
||||
org, meta_repo, md_path = projects_mod.entry_location(
|
||||
config, rfc["collection_id"], slug)
|
||||
st = await metadata_mod.read_entry_from_git(gitea, org, meta_repo, md_path)
|
||||
if st is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
raise HTTPException(409, f"Meta entry {md_path} not found on main")
|
||||
if viewer.gitea_login in st.entry.owners:
|
||||
return {"ok": True, "noop": True}
|
||||
ent = metadata_mod.apply_values(
|
||||
st.entry, {"owners": st.entry.owners + [viewer.gitea_login]})
|
||||
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", ent, st)
|
||||
files = metadata_mod.write_entry_files(md_path, ent, st)
|
||||
try:
|
||||
pr = await bot.open_claim_pr(
|
||||
viewer.as_actor(),
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
org=org, meta_repo=meta_repo,
|
||||
slug=slug,
|
||||
files=files,
|
||||
)
|
||||
@@ -521,12 +524,15 @@ def make_router(
|
||||
403, "Only this RFC's owners or a site owner may retire it"
|
||||
)
|
||||
prior_state = rfc["state"]
|
||||
st = await _read_meta_entry(slug)
|
||||
# §22/G-15: resolve repo+path from the entry's collection, not the default.
|
||||
org, meta_repo, md_path = projects_mod.entry_location(
|
||||
config, rfc["collection_id"], slug)
|
||||
st = await _read_meta_entry(org, meta_repo, md_path)
|
||||
entry = metadata_mod.apply_values(st.entry, {"state": "retired"})
|
||||
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", entry, st)
|
||||
files = metadata_mod.write_entry_files(md_path, entry, st)
|
||||
await _run_state_flip(
|
||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
||||
slug=slug, files=files,
|
||||
meta_repo=meta_repo, slug=slug, files=files,
|
||||
verb="retire", target_state="retired",
|
||||
)
|
||||
_audit(
|
||||
@@ -550,14 +556,17 @@ def make_router(
|
||||
viewer = auth.require_user(request)
|
||||
if viewer.role != "owner":
|
||||
raise HTTPException(403, "Only a site owner may un-retire an RFC")
|
||||
_require_retired(slug)
|
||||
rfc = _require_retired(slug)
|
||||
restored = _prior_state_before_retire(slug)
|
||||
st = await _read_meta_entry(slug)
|
||||
# §22/G-15: resolve repo+path from the entry's collection, not the default.
|
||||
org, meta_repo, md_path = projects_mod.entry_location(
|
||||
config, rfc["collection_id"], slug)
|
||||
st = await _read_meta_entry(org, meta_repo, md_path)
|
||||
entry = metadata_mod.apply_values(st.entry, {"state": restored})
|
||||
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", entry, st)
|
||||
files = metadata_mod.write_entry_files(md_path, entry, st)
|
||||
await _run_state_flip(
|
||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
||||
slug=slug, files=files,
|
||||
meta_repo=meta_repo, slug=slug, files=files,
|
||||
verb="unretire", target_state=restored,
|
||||
)
|
||||
_audit(
|
||||
@@ -598,16 +607,14 @@ def make_router(
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not retired")
|
||||
return row
|
||||
|
||||
async def _read_meta_entry(slug: str):
|
||||
async def _read_meta_entry(org: str, repo: str, md_path: str):
|
||||
"""Dual-read an entry from meta-main → EntryGitState (sidecar-aware,
|
||||
§22.4a). A migrated body-only `.md` reads cleanly; never raises on bad
|
||||
metadata (INV-3)."""
|
||||
st = await metadata_mod.read_entry_from_git(
|
||||
gitea, config.gitea_org,
|
||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||
)
|
||||
metadata (INV-3). `org`/`repo`/`md_path` are collection-resolved by the
|
||||
caller (§22/G-15) so a non-default project's entry reads its own repo."""
|
||||
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||
if st is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
raise HTTPException(409, f"Meta entry {md_path} not found on main")
|
||||
return st
|
||||
|
||||
async def _refresh_catalog() -> None:
|
||||
@@ -636,6 +643,7 @@ async def _orchestrate(
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
state: GraduationState,
|
||||
meta_repo: str,
|
||||
graduation_files: list[dict],
|
||||
) -> None:
|
||||
"""Open the flip PR, then merge it. Two steps, no transaction:
|
||||
@@ -654,7 +662,7 @@ async def _orchestrate(
|
||||
try:
|
||||
pr = await bot.open_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
org=config.gitea_org, meta_repo=meta_repo,
|
||||
slug=state.slug,
|
||||
files=graduation_files,
|
||||
rfc_id=state.rfc_id,
|
||||
@@ -673,14 +681,15 @@ async def _orchestrate(
|
||||
try:
|
||||
await bot.merge_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
org=config.gitea_org, meta_repo=meta_repo,
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, rfc_id=state.rfc_id,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
|
||||
await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
|
||||
await _cleanup_unmerged(
|
||||
config=config, bot=bot, actor=actor, state=state, meta_repo=meta_repo)
|
||||
await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
|
||||
return
|
||||
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
|
||||
@@ -723,7 +732,7 @@ async def _orchestrate(
|
||||
|
||||
|
||||
async def _cleanup_unmerged(
|
||||
*, config: Config, bot: Bot, actor: Actor, state: GraduationState,
|
||||
*, config: Config, bot: Bot, actor: Actor, state: GraduationState, meta_repo: str,
|
||||
) -> None:
|
||||
"""A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
|
||||
branch. Close the PR and delete the branch so failed attempts don't
|
||||
@@ -735,7 +744,7 @@ async def _cleanup_unmerged(
|
||||
try:
|
||||
await bot.close_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
org=config.gitea_org, meta_repo=meta_repo,
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, reason="graduation merge failed",
|
||||
@@ -748,7 +757,7 @@ async def _cleanup_unmerged(
|
||||
await bot.delete_branch(
|
||||
actor,
|
||||
owner=config.gitea_org,
|
||||
repo=(projects_mod.default_content_repo(config) or ""),
|
||||
repo=meta_repo,
|
||||
branch=branch_name,
|
||||
slug=state.slug,
|
||||
action_kind="delete_post_merge_branch",
|
||||
@@ -843,6 +852,7 @@ async def _run_state_flip(
|
||||
gitea: Gitea,
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
files: list[dict],
|
||||
verb: str,
|
||||
@@ -858,7 +868,7 @@ async def _run_state_flip(
|
||||
try:
|
||||
pr = await bot.open_retire_flip_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
org=config.gitea_org, meta_repo=meta_repo,
|
||||
slug=slug, files=files,
|
||||
verb=verb, target_state=target_state,
|
||||
)
|
||||
@@ -869,7 +879,7 @@ async def _run_state_flip(
|
||||
try:
|
||||
await bot.merge_retire_flip_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
org=config.gitea_org, meta_repo=meta_repo,
|
||||
pr_number=pr_number, head_branch=head_branch,
|
||||
slug=slug, verb=verb,
|
||||
)
|
||||
@@ -878,12 +888,12 @@ async def _run_state_flip(
|
||||
# accumulate (mirrors graduation's `_cleanup_unmerged`).
|
||||
try:
|
||||
await bot.close_graduation_pr(
|
||||
actor, org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
actor, org=config.gitea_org, meta_repo=meta_repo,
|
||||
pr_number=pr_number, head_branch=head_branch,
|
||||
slug=slug, reason=f"{verb} merge failed",
|
||||
)
|
||||
await bot.delete_branch(
|
||||
actor, owner=config.gitea_org, repo=(projects_mod.default_content_repo(config) or ""),
|
||||
actor, owner=config.gitea_org, repo=meta_repo,
|
||||
branch=head_branch, slug=slug,
|
||||
action_kind="delete_post_merge_branch",
|
||||
reason=f"{verb} merge failed",
|
||||
|
||||
Reference in New Issue
Block a user