Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 732b23b156 |
@@ -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
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
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/<N>/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
|
## 0.30.0 — 2026-05-29
|
||||||
|
|
||||||
**Minor — documentation: the user guide (`DOCS.md`, served at
|
**Minor — documentation: the user guide (`DOCS.md`, served at
|
||||||
|
|||||||
@@ -219,6 +219,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
|||||||
open_pulls, closed_pulls = [], []
|
open_pulls, closed_pulls = [], []
|
||||||
for pull in open_pulls + closed_pulls:
|
for pull in open_pulls + closed_pulls:
|
||||||
head_branch = pull.get("head", {}).get("ref", "")
|
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)
|
state = _state_from_pull(pull)
|
||||||
gitea_opener = (pull.get("user") or {}).get("login") or ""
|
gitea_opener = (pull.get("user") or {}).get("login") or ""
|
||||||
opened_by = _resolve_actor(
|
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:
|
for pull in open_pulls + closed_pulls:
|
||||||
head_branch = pull.get("head", {}).get("ref", "")
|
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)
|
slug = _slug_from_head_branch(head_branch)
|
||||||
if slug is None:
|
if slug is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -577,6 +577,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
|||||||
assert ("merge_proposal", "ben") in kinds
|
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):
|
def test_slug_uniqueness_enforced(app_with_fake_gitea):
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
app, _fake = app_with_fake_gitea
|
app, _fake = app_with_fake_gitea
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.30.0",
|
"version": "0.30.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user