Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d581010063 | |||
| 732b23b156 | |||
| 1558cc3a8b |
@@ -23,6 +23,88 @@ 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.2 — 2026-05-29
|
||||||
|
|
||||||
|
**Patch — header nav label: the persistent chrome link reverts from
|
||||||
|
"Philosophy" back to "About." Display text only — the route
|
||||||
|
(`/philosophy`), the `header-about` class, and the page itself are
|
||||||
|
unchanged. A plain frontend rebuild applies it; no schema, API, config,
|
||||||
|
overlay, or secret change.**
|
||||||
|
|
||||||
|
The §14.3 persistent link was relabeled "About" → "Philosophy" in
|
||||||
|
v0.21.0. This restores "About" as the neutral, framework-native label
|
||||||
|
(the CSS class `header-about` and the surrounding comment already call
|
||||||
|
it "the About link"). A deployment that wants a more pointed framing
|
||||||
|
can title its own About page in the `PHILOSOPHY.md` content the
|
||||||
|
`PHILOSOPHY_PATH` override serves.
|
||||||
|
|
||||||
|
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**Minor — documentation: the user guide (`DOCS.md`, served at
|
||||||
|
`/docs/user-guide` via `/api/docs`) brought back in sync with the
|
||||||
|
shipped app. No code, schema, API, config, overlay, or secret change —
|
||||||
|
a plain code deploy serves the updated guide. Shipped from driver
|
||||||
|
session 0037.0.**
|
||||||
|
|
||||||
|
The guide had drifted since it was first written: it still described
|
||||||
|
the pre-OTC email *allowlist* sign-in, listed four propose-RFC fields,
|
||||||
|
and predated several shipped surfaces. Updated to match v0.7.0–v0.29.0:
|
||||||
|
|
||||||
|
- **Signing in** rewritten for the email + one-time-code flow (v0.7.0),
|
||||||
|
optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
|
||||||
|
optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
|
||||||
|
`pending` → admin-`granted` gate (v0.8.0 / #6), plus the admin-create
|
||||||
|
+ invite-claim path (v0.17.0 / #16). The vestigial allowlist is no
|
||||||
|
longer described as the gate.
|
||||||
|
- **Proposing a new RFC** now lists five fields, adding the optional
|
||||||
|
"What will you be using this RFC for?" use-case field (#26) and noting
|
||||||
|
the AI tag-suggestion disclosure (#27).
|
||||||
|
- **Roles & permissions** documents the `pending` state.
|
||||||
|
- New **Invitations, cross-references, and contribution requests**
|
||||||
|
section covers owner invitations (#12) and the RFC auto-link /
|
||||||
|
create-RFC / ask-to-contribute affordances (#28).
|
||||||
|
- New **Privacy and cookies** section covers the consent banner and
|
||||||
|
consent-gated analytics (#11 / #13).
|
||||||
|
|
||||||
|
Upgrade steps: none — documentation-only; the change is the `DOCS.md`
|
||||||
|
file served verbatim by `/api/docs`. A plain code deploy at this tag
|
||||||
|
serves it. A deployment that overrides the guide via `DOCS_PATH`
|
||||||
|
supplies its own copy and is unaffected.
|
||||||
|
|
||||||
## 0.29.0 — 2026-05-28
|
## 0.29.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
|
**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
|
||||||
|
|||||||
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
|
|||||||
|
|
||||||
## Signing in
|
## Signing in
|
||||||
|
|
||||||
While the framework is in private beta, only invited email addresses
|
Anyone can start the sign-in flow with their own email address — there
|
||||||
can complete sign-in. If your email is on the allowlist, the
|
is no invite-only allowlist. Sign-in is passwordless:
|
||||||
"Sign in" button in the header completes the flow and lands you on
|
|
||||||
the catalog with full read and write access. If your email is not on
|
|
||||||
the allowlist, you'll be sent to a short "pending" page explaining
|
|
||||||
the gate.
|
|
||||||
|
|
||||||
Once you have an account, you're a **contributor** by default — the
|
1. **Enter your email.** If the deployment has human verification
|
||||||
role that grants every write affordance the app exposes, scoped by
|
enabled (a Cloudflare Turnstile challenge), you complete it here.
|
||||||
the per-RFC and per-branch rules described below.
|
2. **Enter the one-time code.** The app emails you a short numeric
|
||||||
|
code; entering it signs you in. Codes expire after a few minutes,
|
||||||
|
and repeated wrong entries briefly lock the email.
|
||||||
|
3. **Set a passcode (optional).** After your first code sign-in you
|
||||||
|
can set a passcode. On later visits you sign in with email +
|
||||||
|
passcode, with the one-time code as the forgot-passcode fallback.
|
||||||
|
4. **Trust this device (optional).** You can mark a device trusted for
|
||||||
|
30 days to skip the code/passcode step on it. Trusted devices are
|
||||||
|
listed in your settings and can be revoked individually or all at
|
||||||
|
once.
|
||||||
|
|
||||||
|
### Getting write access
|
||||||
|
|
||||||
|
Signing in gives you an account, but write access is gated. The first
|
||||||
|
time you sign in you're asked for your first name, last name, and a
|
||||||
|
short note on why you'd like access; you then land on a "request in
|
||||||
|
review" page. While your account is **pending**, you can read
|
||||||
|
everything an anonymous visitor can but cannot write — no chat,
|
||||||
|
propose, branch, PR, or discussion post. Once an admin **grants** your
|
||||||
|
account you become a **contributor**, the role that carries every
|
||||||
|
write affordance the app exposes, scoped by the per-RFC and per-branch
|
||||||
|
rules described below.
|
||||||
|
|
||||||
|
An admin can also create your account ahead of time and email you an
|
||||||
|
invite link. Clicking it claims the account and signs you in with the
|
||||||
|
role the admin assigned, skipping the one-time-code step.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Proposing a new RFC
|
## Proposing a new RFC
|
||||||
|
|
||||||
A new RFC begins as a proposal. The "+ Propose new RFC" button at
|
A new RFC begins as a proposal. The "+ Propose new RFC" button at
|
||||||
the bottom of the catalog opens a small modal that collects four
|
the bottom of the catalog opens a small modal that collects five
|
||||||
things:
|
things:
|
||||||
|
|
||||||
- **Title.** The word, concept, or topic this RFC would define.
|
- **Title.** The word, concept, or topic this RFC would define.
|
||||||
@@ -65,8 +86,13 @@ things:
|
|||||||
inline.
|
inline.
|
||||||
- **Pitch.** One or two paragraphs answering *why this RFC is
|
- **Pitch.** One or two paragraphs answering *why this RFC is
|
||||||
needed*. This becomes the body of the entry.
|
needed*. This becomes the body of the entry.
|
||||||
- **Tags.** Optional. The AI suggests tags from the pitch; you can
|
- **Use case.** Optional. *What will you be using this RFC for?* —
|
||||||
accept, dismiss, or type your own.
|
the concrete application driving the proposal, as distinct from the
|
||||||
|
abstract case for it. Leaving it blank is fine.
|
||||||
|
- **Tags.** Optional. If the deployment has AI tag suggestion
|
||||||
|
enabled, suggested tags appear as you fill the form (with an inline
|
||||||
|
note that the text you've entered is sent to the model that
|
||||||
|
generates them); you can accept, dismiss, or type your own.
|
||||||
|
|
||||||
Submitting the modal does one concrete thing: it opens a pull
|
Submitting the modal does one concrete thing: it opens a pull
|
||||||
request against the framework's meta repository, adding one new
|
request against the framework's meta repository, adding one new
|
||||||
@@ -167,6 +193,51 @@ either requires a contributor account.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Invitations, cross-references, and contribution requests
|
||||||
|
|
||||||
|
Three connected surfaces help the right people find and join the
|
||||||
|
right RFC.
|
||||||
|
|
||||||
|
### Owner invitations
|
||||||
|
|
||||||
|
An RFC's owner (or an app-wide admin or owner) can invite a specific
|
||||||
|
person to that RFC from the "Invitations" control in the RFC header.
|
||||||
|
The invite names an email and a role for *this RFC*:
|
||||||
|
|
||||||
|
- **contributor** — can open PRs and join the discussion;
|
||||||
|
- **discussant** — can join the discussion only.
|
||||||
|
|
||||||
|
The invitee gets an email with an accept link; accepting adds them as
|
||||||
|
a collaborator on that RFC. The invitations panel lists every invite
|
||||||
|
with its status (pending / accepted / expired / revoked); pending
|
||||||
|
invites can be revoked. An invitation is per-RFC — it does not change
|
||||||
|
the invitee's app-wide role, and it cannot lift the pending gate: the
|
||||||
|
invitee still needs a granted account to write.
|
||||||
|
|
||||||
|
### RFC cross-links in PRs and comments
|
||||||
|
|
||||||
|
When a PR description or a comment mentions an existing active RFC —
|
||||||
|
by its ID, its multi-word title, or its slug — the framework renders
|
||||||
|
that mention as a link to the RFC. The matching is conservative by
|
||||||
|
design (single common words are never auto-linked), and the links are
|
||||||
|
computed at read time, so nothing is rewritten in what you typed.
|
||||||
|
|
||||||
|
### "Create" and "ask to contribute" offers
|
||||||
|
|
||||||
|
The same scan surfaces two affordances inline:
|
||||||
|
|
||||||
|
- If a term looks like it should have an RFC but none exists yet, a
|
||||||
|
reader who has create rights sees a **"create RFC for '<term>'"**
|
||||||
|
link that opens the propose modal with the title pre-filled.
|
||||||
|
- If a term matches a *pending* RFC (a super-draft someone already
|
||||||
|
owns), a signed-in reader sees an **"ask to contribute"** offer
|
||||||
|
naming the owner. It opens a short request form — who you are, why
|
||||||
|
you're asking, and optionally what you'd use the RFC for. The
|
||||||
|
request lands in the owner's inbox; the owner can **accept** (which
|
||||||
|
sends you an owner invitation) or **decline** (which notifies you).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Working on a branch
|
## Working on a branch
|
||||||
|
|
||||||
Contribute mode flips one branch into edit-enabled. The centre
|
Contribute mode flips one branch into edit-enabled. The centre
|
||||||
@@ -505,6 +576,14 @@ Each role is a strict superset of the one below it.
|
|||||||
entirely. The framework names a single "owner zero" at
|
entirely. The framework names a single "owner zero" at
|
||||||
bootstrap.
|
bootstrap.
|
||||||
|
|
||||||
|
Between anonymous and contributor sits one transient state:
|
||||||
|
**pending**. A freshly signed-in account that hasn't been granted
|
||||||
|
access yet (see [Signing in](#signing-in)) reads everything an
|
||||||
|
anonymous visitor can, but no write affordance unlocks until an admin
|
||||||
|
grants it. Granting promotes the account to contributor; an admin can
|
||||||
|
also revoke a granted account back to a no-write state. These
|
||||||
|
transitions are recorded in the `permission_events` log.
|
||||||
|
|
||||||
The practical difference between admin and owner is narrow but
|
The practical difference between admin and owner is narrow but
|
||||||
load-bearing: admin is the operational tier — it does the day-to-
|
load-bearing: admin is the operational tier — it does the day-to-
|
||||||
day moderation and stewardship work; owner is the tier that
|
day moderation and stewardship work; owner is the tier that
|
||||||
@@ -594,6 +673,20 @@ review.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Privacy and cookies
|
||||||
|
|
||||||
|
A consent banner appears on your first visit and lets you choose
|
||||||
|
which cookie categories to allow — essential always, with analytics
|
||||||
|
and other categories opt-in. The choice is remembered and can be
|
||||||
|
changed any time from the privacy/cookies controls in settings.
|
||||||
|
|
||||||
|
Analytics only load if you opt in: the framework defers the analytics
|
||||||
|
SDK behind your consent, so declining means it is never initialized.
|
||||||
|
The `/privacy` and `/cookies` pages describe what's collected and
|
||||||
|
why; a deployment can point those pages at its own fuller policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Where to learn more
|
## Where to learn more
|
||||||
|
|
||||||
- The framework's *why* lives in [the philosophy
|
- The framework's *why* lives in [the philosophy
|
||||||
|
|||||||
@@ -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.29.0",
|
"version": "0.30.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export default function App() {
|
|||||||
wonders why a conversation is public can reach the answer
|
wonders why a conversation is public can reach the answer
|
||||||
in two clicks. Anonymous viewers see it too. */}
|
in two clicks. Anonymous viewers see it too. */}
|
||||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||||
Philosophy
|
About
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/docs" className="header-about" title="User guide">
|
<Link to="/docs" className="header-about" title="User guide">
|
||||||
Docs
|
Docs
|
||||||
|
|||||||
Reference in New Issue
Block a user