From 732b23b156dbe813230d9f3bca89b128e08df31f Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 29 May 2026 05:26:00 -0700 Subject: [PATCH] v0.30.1: fix phantom pending-idea after merge-with-branch-delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh_meta_pulls / refresh_rfc_repo recover a PR's slug from its Gitea head.ref, which collapses to the refs/pull//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) --- CHANGELOG.md | 32 +++++++++ VERSION | 2 +- backend/app/cache.py | 32 +++++++++ backend/tests/test_propose_vertical.py | 91 ++++++++++++++++++++++++++ frontend/package.json | 2 +- 5 files changed, 157 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c96f760..4dd8706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,38 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.30.1 — 2026-05-29 + +**Patch — bug fix: a merged idea-PR whose branch was deleted no longer +lingers as a phantom "pending idea." No schema, API, config, overlay, +or secret change — a plain code deploy applies it, and the running +reconciler clears any existing ghost on the next sweep (≤5 min) once +deployed. Shipped from driver session 0040.0.** + +`refresh_meta_pulls` (and `refresh_rfc_repo`) recover a PR's slug/kind +by parsing its Gitea `head.ref`. When a PR is merged **and its branch +deleted**, Gitea stops reporting the real branch name and returns the +synthetic `refs/pull//head` sentinel instead. The slug then parsed +to `None`, the reconcile loop skipped the row, and `cached_prs.state` +stayed frozen at `open` forever — so the entry showed as **both** a +super-draft (the `cached_rfcs` push-event reconcile succeeded) **and** a +pending idea (the `cached_prs` PR-close reconcile never landed). The fix +recovers the original branch name from the already-stored `cached_prs` +row (which retains the real `head_branch` from when the PR was open; +migration 002) whenever Gitea reports an empty or `refs/pull/` sentinel +ref. Regression test added in `test_propose_vertical.py` +(`test_merged_idea_pr_with_deleted_branch_clears_proposal`). + +Surfaced through the ROADMAP #35 operator authoring lane, which merges +idea PRs from the CLI with branch-deletion enabled — a path the web UX +never exercises (it leaves branches in place, so +`default_delete_branch_after_merge` stays false). The framework should +not depend on branches outliving their merge, hence the framework-level +fix rather than a tooling workaround. + +Upgrade steps: none. **SHOULD** deploy as a normal code deploy; the +periodic reconciler self-heals any existing phantom on its next sweep. + ## 0.30.0 — 2026-05-29 **Minor — documentation: the user guide (`DOCS.md`, served at diff --git a/VERSION b/VERSION index c25c8e5..1a44cad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.30.0 +0.30.1 diff --git a/backend/app/cache.py b/backend/app/cache.py index c3bdbc0..1fe0815 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -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//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//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 diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py index c55f2f4..e0bfc70 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -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//head` sentinel instead of + `propose/`. `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 diff --git a/frontend/package.json b/frontend/package.json index 9d74d4c..ec335f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.30.0", + "version": "0.30.1", "type": "module", "scripts": { "dev": "vite",