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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user