v0.30.1: fix phantom pending-idea after merge-with-branch-delete

refresh_meta_pulls / refresh_rfc_repo recover a PR's slug from its
Gitea head.ref, which collapses to the refs/pull/<N>/head sentinel
once a merged PR's branch is deleted. The slug then parsed to None,
the row was skipped, and cached_prs.state froze at 'open' — so the
entry showed as both a super-draft and a pending idea. Recover the
real branch name from the stored cached_prs row when Gitea reports an
empty or sentinel ref.

Surfaced via the ROADMAP #35 operator authoring lane (CLI merge with
--delete-branch); the web UX leaves branches in place so it never hit
this. Regression test added; full suite 375 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-29 05:26:00 -07:00
parent 1558cc3a8b
commit 732b23b156
5 changed files with 157 additions and 2 deletions
+91
View File
@@ -577,6 +577,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
assert ("merge_proposal", "ben") in kinds
def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
"""Regression: a merged idea PR whose branch was deleted must not
linger as a 'pending idea' ghost.
Found via the ROADMAP #35 operator authoring lane: merging an idea
PR from the CLI with `--delete-branch` makes Gitea report the PR's
`head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
`propose/<slug>`. `refresh_meta_pulls` derives the slug from the
branch name, so the sentinel parsed to slug=None, the row was skipped,
and `cached_prs.state` stayed frozen at 'open' — leaving the entry
showing as BOTH a super-draft (cached_rfcs reconciled off the push)
AND a pending idea (cached_prs never updated). The fix recovers the
original branch name from the already-stored cached_prs row.
The web UX never tripped this because it leaves the branch in place
(the repo's default_delete_branch_after_merge is false).
The bug only manifests on an out-of-band merge (the PR merged +
branch deleted directly in Gitea, with the in-app merge endpoint never
reconciling the row while the branch still existed) -- which is exactly
what the #35 CLI lane does. An in-app merge reconciles cached_prs to
'merged' before the branch is gone, so it never trips this; the test
therefore drives the Gitea state directly to reproduce the CLI path.
"""
from fastapi.testclient import TestClient
from app import db, cache, gitea as gitea_mod
from app.config import load_config
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Informed Consent",
"slug": "informed-consent",
"pitch": "A first-class definition of consent in OHM.",
"tags": [],
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# The proposal is cached as an open idea PR.
items = client.get("/api/proposals").json()["items"]
assert any(i["pr_number"] == pr_number for i in items)
# Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
# its branch deleted directly in Gitea, WITHOUT the in-app merge
# endpoint ever running. So cached_prs still says state='open' and
# Gitea now reports the merged PR's head.ref as the sentinel. This
# is the exact state `rfc-authoring.sh pr-merge --delete-branch`
# leaves behind.
for pr in fake.pulls[("wiggleverse", "meta")]:
if pr["number"] == pr_number:
# land the file on main (the push side already reconciles
# cached_rfcs into a super-draft via the webhook/sweep)
for (o, rp, br, p), data in list(fake.files.items()):
if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
pr["state"] = "closed"
pr["merged"] = True
pr["merged_at"] = "2026-05-29T12:13:00Z"
pr["closed_at"] = "2026-05-29T12:13:00Z"
pr["merge_commit_sha"] = fake._next_sha()
pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)
# The reconcile sweep runs (a later webhook, or the 5-min safety net).
import asyncio
cfg = load_config()
gclient = gitea_mod.Gitea(cfg)
asyncio.run(cache.refresh_meta_repo(cfg, gclient))
asyncio.run(cache.refresh_meta_pulls(cfg, gclient))
# The bug: this used to still list informed-consent (frozen 'open'
# row, slug unparseable from the sentinel). The fix recovers the
# stored branch name, so the row reconciles to merged and the ghost
# is gone.
assert client.get("/api/proposals").json()["items"] == []
# And the cached_prs row is correctly merged, not a frozen 'open'.
row = db.conn().execute(
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
).fetchone()
assert row["state"] == "merged", f"expected merged, got {row['state']}"
# The super-draft itself is unaffected — still in the catalog.
items = client.get("/api/rfcs").json()["items"]
assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)
def test_slug_uniqueness_enforced(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea