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:
@@ -219,6 +219,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
open_pulls, closed_pulls = [], []
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
|
||||
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
|
||||
# the slug is known (param), so state still updates correctly and
|
||||
# no ghost forms — but blindly storing the sentinel would clobber
|
||||
# the real branch name api_prs.py relies on as a fallback ref when
|
||||
# the merge commit is gone. Recover it from the stored row.
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
state = _state_from_pull(pull)
|
||||
gitea_opener = (pull.get("user") or {}).get("login") or ""
|
||||
opened_by = _resolve_actor(
|
||||
@@ -431,6 +444,25 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# A merged-and-deleted PR's branch is no longer reported by Gitea
|
||||
# as its real name — the `head.ref` collapses to the synthetic
|
||||
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
|
||||
# derive from the branch name, so a deleted branch would parse to
|
||||
# slug=None and the row would be skipped forever, freezing the
|
||||
# cached_prs row at its last-seen `state='open'` — a permanent
|
||||
# ghost "pending idea" for an entry that has actually merged
|
||||
# (caught when the operator authoring lane in ROADMAP #35 merged
|
||||
# an idea PR with the branch deleted; the web UX leaves branches
|
||||
# in place so it never tripped this). Recover the original branch
|
||||
# from the row we already stored when the PR was open — that row
|
||||
# retains the real `head_branch` (migration 002).
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
slug = _slug_from_head_branch(head_branch)
|
||||
if slug is None:
|
||||
continue
|
||||
|
||||
@@ -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