Compare commits

..

8 Commits

Author SHA1 Message Date
Ben Stull f7b93d797c Merge pull request 'feat(§8.12/§8.3): main-view Ask cuts an edit branch and runs the question there (v0.54.0)' (#49) from fix-main-view-ask into main 2026-06-09 11:20:30 +00:00
Ben Stull 24596842ea feat(§8.12/§8.3): main-view Ask cuts an edit branch and runs the question there (v0.54.0)
The AI "Ask" affordance (selection tooltip + prompt bar) had no response
surface on an entry's canonical `main` view: RFCView renders the human-
discussion panel there, not the chat panel, so a chat turn posted from the
reading view had nowhere to render — asking-while-reading silently did
nothing. AI chat is an editing activity (a turn can emit <change> proposals)
and only runs on an edit branch.

Option B: when Ask is invoked from main, transparently cut an edit branch via
the same dispatch as Start Contributing (promote-to-branch for active,
start-edit-branch for super-draft; idempotent — reuse an existing edit
branch), navigate onto it so the chat panel mounts, and run the question
(text + selected quote) as the branch's first chat turn once its view and
main_thread_id resolve. The turn fires from the message-load effect's
continuation (not a parallel effect) so a late message load can't clobber the
optimistic turn; a live ref keeps the latest submitChatTurn closure in reach.

Degrades gracefully: signed-out → login; a viewer who can't contribute has the
cut rejected server-side and the error surfaced (no spurious branch); asking
from a branch already, and Flag on main (human discussion thread), unchanged.

Frontend-only; the branch chat endpoints already work on a branch. SPEC §8.3
records the behavior; new RFCView unit tests + an e2e spec cover the flow.
backend 677 / frontend 66 / e2e 5 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:19:20 -07:00
Ben Stull ff88be2e91 Merge pull request 'fix(§22/G-15): three-tier-aware branch/edit/body subsystem (v0.53.0)' (#47) from fix-g15-three-tier-write-paths into main 2026-06-09 05:40:59 +00:00
Ben Stull 4e7410f90b fix(§22/G-15): make the branch/edit/body subsystem three-tier aware (v0.53.0)
§22 migrated the READ/catalog path to the three-tier (project/collection)
model but the WRITE/branch/body subsystem still hardcoded the default
project's default collection — resolving every meta-resident entry to the
default content repo at rfcs/<slug>.md, ignoring the entry's project (its own
content repo) and collection (a <subfolder>/rfcs/ prefix). An entry outside
the default collection rendered a blank canonical body and every edit/PR/
body-write path hit the wrong file.

- New single resolver: projects.content_repo_for_collection() +
  projects.entry_location(config, cid, slug) -> (org, repo, md_path)
  (collection -> project -> content_repo + subfolder; falls back to the
  default repo for a legacy/unknown collection).
- Every entry write/read path resolves via it: api_branches (body GET + all
  branch write paths), api_prs (pr-draft/open/merge/withdraw/review/
  resolution-branch), api_graduation (graduate/claim/retire/unretire +
  orchestrator + state-flip), api.py mark-reviewed + idea-PR merge/decline/
  withdraw + proposal preview, api_metadata. bot.open_metadata_pr and
  bot.mark_entry_reviewed gained a file_path param. refresh_meta_branches,
  the webhook corpus-refresh dispatch, and the hygiene branch-delete now
  span every project's content repo, not just the default.
- New additive collection-scoped body-read routes:
  GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main and
  .../branches/{branch} disambiguate a slug across collections (G-5) and read
  the entry's own repo. Slug-only routes kept (now collection-aware via the
  cached row). Frontend getRFCMain/getBranch take optional pid+cid; RFCView
  threads them (mirrors the v0.52.1 entry-detail fix).

No migration, no config change. Existing default-collection entries
unaffected. Tests: backend resolver + collection-scoped branch/body + graduate-
in-subfolder write path; frontend api unit. backend 677 / frontend 60 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:39:46 -07:00
Ben Stull afa8d26378 Merge pull request 'fix(routing): 404 page for unmatched routes (v0.52.3)' (#45) from fix-404-unmatched-routes into main 2026-06-09 03:14:39 +00:00
Ben Stull 948ee88160 fix(routing): render a 404 page for unmatched routes (v0.52.3)
The top-level catch-all silently redirected unknown paths to "/", and the
nested /admin/*, /p/:projectId/*, and /docs/* route groups had no catch-all
(invalid subpaths rendered a blank pane). Add a shared NotFound component and
wire it into all four route groups so a bad/typo'd URL shows a clear 404 with
a link home. Client-side 404 UI (SPA still serves HTTP 200).

Patch 0.52.2 → 0.52.3; CHANGELOG updated. Caught by the operator on PPE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:14:25 -07:00
Ben Stull 7bcf784d06 Merge pull request 'fix(admin): absolute sidebar nav links (v0.52.2)' (#44) from fix-admin-nav-relative-links into main 2026-06-09 03:08:25 +00:00
Ben Stull 8e207a60e6 fix(admin): absolute sidebar nav links so /admin URLs don't accumulate (v0.52.2)
The /admin left-rail NavLinks used relative targets (to="users", etc.). Under
the /admin/* nested route a relative link resolves against the current URL, so
each click appended a segment — Users→Allowlist→Graduation yielded
/admin/users/allowlist/graduation instead of /admin/graduation. Use absolute
targets (/admin/<tab>) + `end` for exact active matching.

Patch 0.52.1 → 0.52.2; CHANGELOG updated. Caught by the operator on PPE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:44 -07:00
27 changed files with 1203 additions and 168 deletions
+109
View File
@@ -23,6 +23,115 @@ 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.54.0 — 2026-06-09
**Minor — the AI "Ask" affordance now works from an entry's canonical view
(§8.12); no operator action required.**
AI chat is an editing activity (a turn can emit proposed edits) and only runs
on an edit branch. On an entry's canonical `main` view the right column is the
human-discussion panel, not the chat panel — so invoking "Ask" (the selection
tooltip or the prompt bar) from the reading view pushed a chat turn that had
nowhere to render: it silently did nothing. Surfaced by dogfooding a non-default
project's spec entry on a live deployment.
- **Asking while reading "just works" (Option B).** When Ask is invoked from
the canonical view, the app transparently cuts an edit branch via the same
dispatch as Start Contributing (`start-edit-branch` for a super-draft,
`promote-to-branch` for an active RFC — idempotent, so it reuses an existing
edit branch rather than double-cutting), navigates onto that branch so the
chat panel mounts, and runs the question (text + the selected quote) there
once the branch's chat thread has resolved. The viewer lands in the chat with
the streaming answer, quote intact.
- **Graceful degradation.** A signed-out viewer keeps the read-only path
(sign-in prompt; Ask disabled) and a viewer who cannot contribute has the
branch cut rejected server-side and the error surfaced — no silent no-op and
no spurious branch. Asking from a branch already, and Flag on the canonical
view (which still opens a human discussion thread), are unchanged.
Frontend-only: the branch chat endpoints already worked on a branch. No
database migration and no configuration change.
## 0.53.0 — 2026-06-09
**Minor — the branch/edit/body subsystem is now three-tier (project /
collection) aware (gap G-15); no operator action required.**
§22 migrated the READ/catalog path to the three-tier model, but the
WRITE/branch/body subsystem still hardcoded the default project's default
collection: it resolved every meta-resident entry to the default project's
content repo at `rfcs/<slug>.md`, ignoring the entry's project (its own
content repo) and its collection (a `<subfolder>/rfcs/` prefix). As a result
an entry outside the default project's default collection rendered a **blank
canonical body** (the git-backed `GET …/branches/main` read the wrong file)
and every edit / PR / body-write path hit the wrong repo. Surfaced by
dogfooding a separate project on a live deployment.
- **Single resolver.** New `projects.content_repo_for_collection(cid)` and
`projects.entry_location(config, cid, slug) → (org, repo, md_path)` resolve
an entry's git location from its collection (collection → project →
content_repo, plus the collection subfolder), falling back to the default
project's repo for a legacy / unknown collection. Every write/read path now
shares this resolver instead of the hardcoded default.
- **Every entry write/read path is collection-resolved:** the branch-body GET
and all branch write paths (`api_branches`: start-edit-branch, accept /
decline / reask, manual-flush, promote-to-branch, metadata, branch chat),
the PR family (`api_prs`: pr-draft / open-pr / merge / withdraw / review /
resolution-branch), graduation (`api_graduation`: graduate / claim / retire
/ unretire and the module-level orchestrator + state-flip helpers),
mark-reviewed, the idea-PR merge / decline / withdraw + proposal preview,
and metadata edit/bulk/migrate (`api_metadata`). `bot.open_metadata_pr` and
`bot.mark_entry_reviewed` gained a `file_path` parameter (was hardcoded to
the repo root). The branch cache (`refresh_meta_branches`), the webhook
corpus-refresh dispatch, and the hygiene branch-delete now span **every**
project's content repo, not just the default.
- **Collection-scoped body-read routes** (new, additive):
`GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main` and
`…/rfcs/{slug}/branches/{branch}` disambiguate a slug that exists in two
collections (G-5) and read the entry's own repo. The slug-only routes are
kept and are now collection-aware via the entry's cached row, so existing
clients are unaffected.
- **Frontend.** `getRFCMain` / `getBranch` take optional `projectId` +
`collectionId` and call the scoped routes when present (RFCView threads its
`pid`/`cid`, mirroring the v0.52.1 entry-detail fix); both fall back to the
slug-only routes otherwise.
No database migration and no configuration change. Existing default-collection
entries are unaffected (the resolver returns the default repo and a repo-root
path for them).
_Known boundary:_ the branch / thread / change / PR cache tables remain
slug-keyed, so a slug that genuinely exists in two collections is still
ambiguous on the slug-only *write* routes; the collection-scoped body-read
routes resolve it for the canonical view, and the resolver makes every write
target the correct repo via the entry's own collection. Full slug
de-duplication across collections on the write family is tracked separately.
## 0.52.3 — 2026-06-09
**Patch — unmatched routes render a 404 instead of a blank page or a
silent redirect home (no operator action required).**
- **The top-level catch-all redirected any unknown path to `/`**, and the
nested `/admin/*`, `/p/:projectId/*`, and `/docs/*` route groups had no
catch-all (so an invalid subpath rendered an empty pane). Added a shared
`NotFound` 404 page and wired it into all four route groups, so a bad or
typo'd URL — including a stale `/admin/users/allowlist/graduation`-style
path — now shows a clear "page not found" with a link back to the catalog.
(Client-side 404 UI; the SPA still serves over HTTP 200, as before.)
## 0.52.2 — 2026-06-09
**Patch — admin sidebar nav links no longer accumulate URL segments (no
operator action required).**
- **The `/admin` left-rail links were relative**, so under the `/admin/*`
nested route each click resolved against the current URL and *appended* a
segment — e.g. clicking Users → Allowlist → Graduation produced
`/admin/users/allowlist/graduation` instead of `/admin/graduation`. Fixed:
the rail `NavLink`s now use absolute targets (`/admin/<tab>`) with `end`
for exact active-state matching.
## 0.52.1 — 2026-06-08
**Patch — collection-scoped entry-detail fetch fix (no operator action
+14
View File
@@ -1022,6 +1022,20 @@ the user on it in contribute mode. New-branch naming defaults to an
auto-generated value (user-renamable); the exact format is an
implementation detail.
The AI chat is an editing activity — a turn can emit `<change>`
proposals — so it runs on an edit branch, not on read-only main, whose
right column is the human-discussion surface (§8.12 is branch-scoped).
Invoking the AI **Ask** affordance (the selection tooltip's prompt, or
the prompt bar) from main therefore transparently cuts an edit branch
via the same dispatch as "Start Contributing" (promote-to-branch for an
active RFC, start-edit-branch for a super-draft per §9.5; idempotent, so
an existing edit branch is reused rather than a second one cut), lands
the user on it, and runs the question — text plus any selected quote —
as the branch's first chat turn. A viewer who cannot contribute has the
branch cut rejected and the error surfaced (no branch is created); a
signed-out viewer keeps the §8.7 read-only path. **Flag** from main is
unaffected — it opens a human discussion thread on main, not a branch.
Discuss vs. contribute is an *intent* affordance, not a *permission*
affordance. A user without contribute access to a branch sees the
toggle disabled, with a sign-in or request-access path (see §8.7).
+1 -1
View File
@@ -1 +1 @@
0.52.1
0.54.0
+26 -6
View File
@@ -919,14 +919,18 @@ def make_router(
raise HTTPException(404, "Not found")
if row["state"] != "active" or not row["unreviewed"]:
raise HTTPException(409, "Entry is not an unreviewed active entry")
# §22/G-15: write to the entry's project content_repo + collection
# subfolder, not the deployment default.
org, meta_repo, md_path = projects_mod.entry_location(config, collection_id, slug)
try:
await bot.mark_entry_reviewed(
viewer.as_actor(),
org=config.gitea_org,
meta_repo=(projects_mod.default_content_repo(config) or ""),
org=org,
meta_repo=meta_repo,
slug=slug,
reviewed_by=viewer.gitea_login,
reviewed_at=entry_mod.today(),
file_path=md_path,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
@@ -1031,7 +1035,19 @@ def make_router(
# Read the proposed entry file from the head branch.
slug = row["rfc_slug"]
head = row["head_branch"]
result = await gitea.read_file(config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref=head)
# §22/G-15: the proposal lives in its project's content_repo under its
# collection's `<subfolder>/rfcs/`. cached_prs carries project_id but not
# collection_id, so resolve the repo from the project and locate the file
# by trying each of the project's collection subfolders (default first).
repo = (projects_mod.content_repo(row["project_id"])
or projects_mod.default_content_repo(config) or "")
result = None
for col in collections_mod.list_collections(row["project_id"], include_unlisted=True):
sub = col["subfolder"] or ""
cand = f"{sub}/rfcs/{slug}.md" if sub else f"rfcs/{slug}.md"
result = await gitea.read_file(config.gitea_org, repo, cand, ref=head)
if result:
break
entry_payload: dict[str, Any] | None = None
if result:
text, _sha = result
@@ -1245,7 +1261,9 @@ def make_router(
await bot.merge_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=(projects_mod.default_content_repo(config) or ""),
# §22/G-15: the idea PR lives in its project's content_repo.
meta_repo=(projects_mod.content_repo(row["project_id"])
or projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
)
@@ -1265,7 +1283,8 @@ def make_router(
await bot.decline_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=(projects_mod.default_content_repo(config) or ""),
meta_repo=(projects_mod.content_repo(row["project_id"])
or projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
comment=body.comment,
@@ -1289,7 +1308,8 @@ def make_router(
await bot.withdraw_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=(projects_mod.default_content_repo(config) or ""),
meta_repo=(projects_mod.content_repo(row["project_id"])
or projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
)
+72 -15
View File
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod
from . import auth, cache, chat as chat_layer, collections as collections_mod, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -170,10 +170,7 @@ def make_router(
# open edit branches, open meta-repo body-edit and metadata PRs.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/main")
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc(slug, viewer)
async def _main_payload(rfc, slug: str, viewer) -> dict[str, Any]:
if rfc["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {rfc['state']}")
@@ -294,6 +291,23 @@ def make_router(
"pre_graduation_history": pre_grad,
}
@router.get("/api/rfcs/{slug}/main")
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
return await _main_payload(_require_rfc(slug, viewer), slug, viewer)
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/main")
async def get_rfc_main_scoped(
project_id: str, collection_id: str, slug: str, request: Request,
) -> dict[str, Any]:
# §22/G-15: collection-scoped canonical-body read. Disambiguates a slug
# that exists in two collections (G-5) and resolves the entry's own
# content repo / subfolder via `_repo_for`/`_file_path_for`.
viewer = auth.current_user(request)
_check_collection_in_project(project_id, collection_id)
rfc = _require_rfc(slug, viewer, collection_id=collection_id)
return await _main_payload(rfc, slug, viewer)
# The bare `GET /api/rfcs/<slug>/branches/<branch>` is declared
# at the *bottom* of this router so the more-specific deeper GET
# routes — `branches/{branch:path}/threads` and
@@ -489,6 +503,7 @@ def make_router(
org=owner,
meta_repo=repo,
slug=slug,
file_path=path,
new_file_contents=new_content,
prior_sha=prior_sha,
pr_title=pr_title,
@@ -1048,10 +1063,7 @@ def make_router(
# else, including slashed branch names like `foo/bar`.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc_with_repo(slug, viewer)
async def _branch_view_payload(rfc, slug: str, branch: str, viewer) -> dict[str, Any]:
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -1118,12 +1130,48 @@ def make_router(
"capabilities": capabilities,
}
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc_with_repo(slug, viewer)
return await _branch_view_payload(rfc, slug, branch, viewer)
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/branches/{branch:path}")
async def get_branch_view_scoped(
project_id: str, collection_id: str, slug: str, branch: str, request: Request,
) -> dict[str, Any]:
# §22/G-15: collection-scoped branch-body read (the canonical-body GET
# RFCView renders). Disambiguates a slug across collections (G-5) and
# reads the entry's own content repo / subfolder.
viewer = auth.current_user(request)
_check_collection_in_project(project_id, collection_id)
rfc = _require_rfc_with_repo(slug, viewer, collection_id=collection_id)
return await _branch_view_payload(rfc, slug, branch, viewer)
# ------------------------------------------------------------------
# Permission + state helpers (closures, share `config` etc.)
# ------------------------------------------------------------------
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
def _check_collection_in_project(project_id: str, collection_id: str) -> None:
"""§22/G-15: a collection-scoped route 404s when the collection isn't in
the named project (matches api_metadata's guard)."""
if collections_mod.project_of_collection(collection_id) != project_id:
raise HTTPException(404, "Collection not in project")
def _require_rfc(slug: str, viewer, collection_id: str | None = None):
# §22/G-15: when a collection_id is supplied (the collection-scoped
# body-read routes), scope the lookup to that collection so a slug that
# exists in two collections resolves unambiguously (G-5); otherwise the
# legacy slug-only lookup picks the entry by slug alone.
if collection_id is not None:
row = db.conn().execute(
"SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id "
"FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
(slug, collection_id)).fetchone()
else:
row = db.conn().execute(
"SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id "
"FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
@@ -1131,13 +1179,13 @@ def make_router(
auth.require_project_readable(viewer, row["project_id"])
return row
def _require_rfc_with_repo(slug: str, viewer):
def _require_rfc_with_repo(slug: str, viewer, collection_id: str | None = None):
"""Used by every branch-scoped endpoint. Under the meta-only
topology (§1) the meta repo is the implicit target for every
entry super-draft and active alike so there is no per-RFC
repo check. The name is retained for call-site stability; a
withdrawn entry is still rejected."""
row = _require_rfc(slug, viewer)
row = _require_rfc(slug, viewer, collection_id)
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
@@ -1183,14 +1231,23 @@ def make_router(
return _is_meta_branch_name(branch)
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
# §22/G-15: a meta-resident entry's repo is its COLLECTION's project
# content_repo (collection → project → content_repo), not the deployment
# default — so an entry in a non-default project reads/writes its own
# repo. `entry_location` falls back to the default repo for a legacy /
# unknown collection, preserving the single-corpus behaviour.
if _is_meta_target(rfc, branch):
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
org, repo, _ = projects_mod.entry_location(config, rfc["collection_id"], rfc["slug"])
return org, repo
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc, branch: str = "main") -> str:
# §22/G-15: path is the collection's `<subfolder>/rfcs/<slug>.md`
# (repo root `rfcs/<slug>.md` for a default collection).
if _is_meta_target(rfc, branch):
return f"rfcs/{rfc['slug']}.md"
_, _, path = projects_mod.entry_location(config, rfc["collection_id"], rfc["slug"])
return path
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
+47 -37
View File
@@ -344,12 +344,14 @@ def make_router(
# Dual-read the meta-repo entry once (§22.4a sidecar-aware) — we need its
# git state for the graduation commit and the body to carry through
# unchanged (meta-only keeps the body in the entry, §13.3).
st = await metadata_mod.read_entry_from_git(
gitea, config.gitea_org,
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
)
# §22/G-15: resolve the repo+path from the entry's COLLECTION, not the
# deployment default, so an entry in a non-default project graduates in
# its own content repo / collection subfolder.
org, meta_repo, md_path = projects_mod.entry_location(
config, rfc["collection_id"], slug)
st = await metadata_mod.read_entry_from_git(gitea, org, meta_repo, md_path)
if st is None:
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
raise HTTPException(409, f"Meta entry {md_path} not found on main")
super_draft_entry = st.entry
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
@@ -378,7 +380,7 @@ def make_router(
extra=dict(super_draft_entry.extra),
)
graduation_files = metadata_mod.write_entry_files(
f"rfcs/{slug}.md", graduated_entry, st)
md_path, graduated_entry, st)
state = _new_active(
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
@@ -398,6 +400,7 @@ def make_router(
coro = _orchestrate(
config=config, gitea=gitea, bot=bot,
actor=viewer.as_actor(), state=state,
meta_repo=meta_repo,
graduation_files=graduation_files,
)
if request.query_params.get("_sync") == "1":
@@ -478,21 +481,21 @@ def make_router(
if already:
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
st = await metadata_mod.read_entry_from_git(
gitea, config.gitea_org,
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
)
# §22/G-15: resolve repo+path from the entry's collection, not the default.
org, meta_repo, md_path = projects_mod.entry_location(
config, rfc["collection_id"], slug)
st = await metadata_mod.read_entry_from_git(gitea, org, meta_repo, md_path)
if st is None:
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
raise HTTPException(409, f"Meta entry {md_path} not found on main")
if viewer.gitea_login in st.entry.owners:
return {"ok": True, "noop": True}
ent = metadata_mod.apply_values(
st.entry, {"owners": st.entry.owners + [viewer.gitea_login]})
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", ent, st)
files = metadata_mod.write_entry_files(md_path, ent, st)
try:
pr = await bot.open_claim_pr(
viewer.as_actor(),
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
org=org, meta_repo=meta_repo,
slug=slug,
files=files,
)
@@ -521,12 +524,15 @@ def make_router(
403, "Only this RFC's owners or a site owner may retire it"
)
prior_state = rfc["state"]
st = await _read_meta_entry(slug)
# §22/G-15: resolve repo+path from the entry's collection, not the default.
org, meta_repo, md_path = projects_mod.entry_location(
config, rfc["collection_id"], slug)
st = await _read_meta_entry(org, meta_repo, md_path)
entry = metadata_mod.apply_values(st.entry, {"state": "retired"})
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", entry, st)
files = metadata_mod.write_entry_files(md_path, entry, st)
await _run_state_flip(
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
slug=slug, files=files,
meta_repo=meta_repo, slug=slug, files=files,
verb="retire", target_state="retired",
)
_audit(
@@ -550,14 +556,17 @@ def make_router(
viewer = auth.require_user(request)
if viewer.role != "owner":
raise HTTPException(403, "Only a site owner may un-retire an RFC")
_require_retired(slug)
rfc = _require_retired(slug)
restored = _prior_state_before_retire(slug)
st = await _read_meta_entry(slug)
# §22/G-15: resolve repo+path from the entry's collection, not the default.
org, meta_repo, md_path = projects_mod.entry_location(
config, rfc["collection_id"], slug)
st = await _read_meta_entry(org, meta_repo, md_path)
entry = metadata_mod.apply_values(st.entry, {"state": restored})
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", entry, st)
files = metadata_mod.write_entry_files(md_path, entry, st)
await _run_state_flip(
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
slug=slug, files=files,
meta_repo=meta_repo, slug=slug, files=files,
verb="unretire", target_state=restored,
)
_audit(
@@ -598,16 +607,14 @@ def make_router(
raise HTTPException(409, f"RFC is {row['state']}, not retired")
return row
async def _read_meta_entry(slug: str):
async def _read_meta_entry(org: str, repo: str, md_path: str):
"""Dual-read an entry from meta-main → EntryGitState (sidecar-aware,
§22.4a). A migrated body-only `.md` reads cleanly; never raises on bad
metadata (INV-3)."""
st = await metadata_mod.read_entry_from_git(
gitea, config.gitea_org,
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
)
metadata (INV-3). `org`/`repo`/`md_path` are collection-resolved by the
caller (§22/G-15) so a non-default project's entry reads its own repo."""
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
if st is None:
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
raise HTTPException(409, f"Meta entry {md_path} not found on main")
return st
async def _refresh_catalog() -> None:
@@ -636,6 +643,7 @@ async def _orchestrate(
bot: Bot,
actor: Actor,
state: GraduationState,
meta_repo: str,
graduation_files: list[dict],
) -> None:
"""Open the flip PR, then merge it. Two steps, no transaction:
@@ -654,7 +662,7 @@ async def _orchestrate(
try:
pr = await bot.open_graduation_pr(
actor,
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
org=config.gitea_org, meta_repo=meta_repo,
slug=state.slug,
files=graduation_files,
rfc_id=state.rfc_id,
@@ -673,14 +681,15 @@ async def _orchestrate(
try:
await bot.merge_graduation_pr(
actor,
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
org=config.gitea_org, meta_repo=meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, rfc_id=state.rfc_id,
)
except GiteaError as e:
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
await _cleanup_unmerged(
config=config, bot=bot, actor=actor, state=state, meta_repo=meta_repo)
await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
return
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
@@ -723,7 +732,7 @@ async def _orchestrate(
async def _cleanup_unmerged(
*, config: Config, bot: Bot, actor: Actor, state: GraduationState,
*, config: Config, bot: Bot, actor: Actor, state: GraduationState, meta_repo: str,
) -> None:
"""A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
branch. Close the PR and delete the branch so failed attempts don't
@@ -735,7 +744,7 @@ async def _cleanup_unmerged(
try:
await bot.close_graduation_pr(
actor,
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
org=config.gitea_org, meta_repo=meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, reason="graduation merge failed",
@@ -748,7 +757,7 @@ async def _cleanup_unmerged(
await bot.delete_branch(
actor,
owner=config.gitea_org,
repo=(projects_mod.default_content_repo(config) or ""),
repo=meta_repo,
branch=branch_name,
slug=state.slug,
action_kind="delete_post_merge_branch",
@@ -843,6 +852,7 @@ async def _run_state_flip(
gitea: Gitea,
bot: Bot,
actor: Actor,
meta_repo: str,
slug: str,
files: list[dict],
verb: str,
@@ -858,7 +868,7 @@ async def _run_state_flip(
try:
pr = await bot.open_retire_flip_pr(
actor,
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
org=config.gitea_org, meta_repo=meta_repo,
slug=slug, files=files,
verb=verb, target_state=target_state,
)
@@ -869,7 +879,7 @@ async def _run_state_flip(
try:
await bot.merge_retire_flip_pr(
actor,
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
org=config.gitea_org, meta_repo=meta_repo,
pr_number=pr_number, head_branch=head_branch,
slug=slug, verb=verb,
)
@@ -878,12 +888,12 @@ async def _run_state_flip(
# accumulate (mirrors graduation's `_cleanup_unmerged`).
try:
await bot.close_graduation_pr(
actor, org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
actor, org=config.gitea_org, meta_repo=meta_repo,
pr_number=pr_number, head_branch=head_branch,
slug=slug, reason=f"{verb} merge failed",
)
await bot.delete_branch(
actor, owner=config.gitea_org, repo=(projects_mod.default_content_repo(config) or ""),
actor, owner=config.gitea_org, repo=meta_repo,
branch=head_branch, slug=slug,
action_kind="delete_post_merge_branch",
reason=f"{verb} merge failed",
+10 -5
View File
@@ -54,8 +54,13 @@ def _apply_op(entry: Any, op: str, field: str, value: Any) -> Any:
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
router = APIRouter()
def _content_repo() -> tuple[str, str]:
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
def _content_repo(collection_id: str) -> tuple[str, str]:
# §22/G-15: the COLLECTION's project content_repo, not the deployment
# default — an entry in a non-default project writes its own repo.
# Falls back to the default repo for an unknown collection.
repo = (projects_mod.content_repo_for_collection(collection_id)
or (projects_mod.default_content_repo(config) or ""))
return config.gitea_org, repo
def _md_path(collection_id: str, slug: str) -> str:
sub = collections_mod.subfolder_of(collection_id) or ""
@@ -83,7 +88,7 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
if unknown:
raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(unknown))}")
org, repo = _content_repo()
org, repo = _content_repo(collection_id)
md_path = _md_path(collection_id, slug)
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
if st is None:
@@ -144,7 +149,7 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
raise HTTPException(422, f"op {body.op} requires a tags field")
org, repo = _content_repo()
org, repo = _content_repo(collection_id)
applied: list[str] = []
rejected: list[dict[str, str]] = []
all_ops: list[dict[str, Any]] = []
@@ -195,7 +200,7 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
raise HTTPException(404, "Collection not in project")
if not auth.is_collection_superuser(viewer, collection_id):
raise HTTPException(403, "Owner access required to migrate a collection")
org, repo = _content_repo()
org, repo = _content_repo(collection_id)
subfolder = collections_mod.subfolder_of(collection_id) or ""
try:
result = await metadata_mod.migrate_collection(
+8 -2
View File
@@ -690,14 +690,20 @@ def make_router(
return not rfc["repo"]
def _owner_repo(rfc) -> tuple[str, str]:
# §22/G-15: a meta-resident entry's repo is its COLLECTION's project
# content_repo, not the deployment default (entry_location falls back to
# the default for a legacy/unknown collection).
if _is_meta_resident(rfc):
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
org, repo, _ = projects_mod.entry_location(config, rfc["collection_id"], rfc["slug"])
return org, repo
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc) -> str:
# §22/G-15: the collection's `<subfolder>/rfcs/<slug>.md`.
if _is_meta_resident(rfc):
return f"rfcs/{rfc['slug']}.md"
_, _, path = projects_mod.entry_location(config, rfc["collection_id"], rfc["slug"])
return path
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str:
+15 -8
View File
@@ -450,17 +450,19 @@ class Bot:
org: str,
meta_repo: str,
slug: str,
file_path: str,
new_file_contents: str,
prior_sha: str,
pr_title: str,
pr_description: str,
) -> dict:
"""Per §9.5: a metadata-pane edit (title or tags) on a super-draft
opens a tiny meta-repo PR that touches only the frontmatter of
`rfcs/<slug>.md`. One commit, one PR, easy to triage. The branch
name uses the dash-separated `metadata-<slug>-<6hex>` shape same
routing-friendly form Slice 4 picked for edit branches per the
§19.2 path-routing candidate.
opens a tiny meta-repo PR that touches only the frontmatter of the
entry's `.md`. `file_path` is the collection-resolved path (§22/G-15:
`<subfolder>/rfcs/<slug>.md`), not assumed to be at the repo root. One
commit, one PR, easy to triage. The branch name uses the dash-separated
`metadata-<slug>-<6hex>` shape same routing-friendly form Slice 4
picked for edit branches per the §19.2 path-routing candidate.
"""
import secrets
@@ -471,7 +473,7 @@ class Bot:
result = await self._gitea.update_file(
org,
meta_repo,
f"rfcs/{slug}.md",
file_path,
content=new_file_contents,
sha=prior_sha,
message=commit_message,
@@ -1210,13 +1212,18 @@ class Bot:
slug: str,
reviewed_by: str,
reviewed_at: str,
file_path: str | None = None,
) -> None:
"""Clear §22.4c unreviewed on an active entry by writing its metadata
sidecar on main (§22.4a). Dual-reads the entry (so a migrated body-only
`.md` doesn't crash) and lazy-migrates a legacy `.md` to body-only in the
same commit. Stamps the §6.5 On-behalf-of trailer and writes an
actions-log row, mirroring the graduation stamp's bot-write shape."""
path = f"rfcs/{slug}.md"
actions-log row, mirroring the graduation stamp's bot-write shape.
`file_path` is the collection-resolved entry path (§22/G-15:
`<subfolder>/rfcs/<slug>.md`); it defaults to the repo-root
`rfcs/<slug>.md` for the legacy single-corpus / default-collection case."""
path = file_path or f"rfcs/{slug}.md"
st = await metadata_mod.read_entry_from_git(self._gitea, org, meta_repo, path)
if st is None:
raise GiteaError(404, f"{path} not found")
+65 -53
View File
@@ -426,73 +426,85 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
of slashes per the §19.2 path-routing candidate.
"""
org = config.gitea_org
repo = projects_mod.default_content_repo(config)
if not repo:
log.warning("refresh_meta_branches: default project has no content_repo yet; skipping")
return
try:
branches = await gitea.list_branches(org, repo)
except GiteaError as e:
log.warning("refresh_meta_branches: %s", e)
# §22/G-15: scan EVERY project's content_repo (not just the default), so an
# entry in a non-default project gets its edit branches + synthesized `main`
# row cached and its branch dropdown / has-commits-ahead check work. Mirrors
# refresh_meta_pulls' per-project loop.
prows = db.conn().execute(
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
).fetchall()
if not prows:
log.warning("refresh_meta_branches: no projects with a content_repo yet; skipping")
return
meta_main_sha = ""
meta_main_ts = None
edit_keys_seen: set[tuple[str, str]] = set()
for b in branches:
name = b.get("name") or ""
head_sha = (b.get("commit") or {}).get("id") or ""
last_commit_at = (b.get("commit") or {}).get("timestamp")
if name == "main":
meta_main_sha = head_sha
meta_main_ts = last_commit_at
for prow in prows:
repo = prow["content_repo"]
try:
branches = await gitea.list_branches(org, repo)
except GiteaError as e:
log.warning("refresh_meta_branches: %s (%s)", e, repo)
continue
slug = _slug_from_branch_name(name)
if not slug:
continue
rfc = db.conn().execute(
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
# Meta-only topology (§1): edit branches live on the meta repo for
# every meta-resident entry — super-drafts and active RFCs alike
# (active RFCs are graduated in place and keep editing here, §13).
# A legacy per-RFC repo (repo set) is the only thing excluded.
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
continue
edit_keys_seen.add((slug, name))
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
""",
(slug, name, head_sha, last_commit_at),
)
# Synthesize a per-slug `main` row for every super-draft entry, so the
# §10.1 has-commits-ahead check in api_prs.py works uniformly. The
# head_sha is the meta-repo main's tip — every super-draft edit branch
# diverges from this single point.
if meta_main_sha:
super_drafts = db.conn().execute(
"SELECT slug FROM cached_rfcs "
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
).fetchall()
for r in super_drafts:
meta_main_sha = ""
meta_main_ts = None
for b in branches:
name = b.get("name") or ""
head_sha = (b.get("commit") or {}).get("id") or ""
last_commit_at = (b.get("commit") or {}).get("timestamp")
if name == "main":
meta_main_sha = head_sha
meta_main_ts = last_commit_at
continue
slug = _slug_from_branch_name(name)
if not slug:
continue
rfc = db.conn().execute(
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
# Meta-only topology (§1): edit branches live on the content repo for
# every meta-resident entry — super-drafts and active RFCs alike
# (active RFCs are graduated in place and keep editing here, §13).
# A legacy per-RFC repo (repo set) is the only thing excluded.
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
continue
edit_keys_seen.add((slug, name))
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
""",
(r["slug"], meta_main_sha, meta_main_ts),
(slug, name, head_sha, last_commit_at),
)
# Synthesize a per-slug `main` row for this project's super-draft/active
# entries, so the §10.1 has-commits-ahead check works uniformly. The
# head_sha is this content repo's main tip — every edit branch in the
# project diverges from that single point.
if meta_main_sha:
super_drafts = db.conn().execute(
"SELECT r.slug AS slug FROM cached_rfcs r "
"JOIN collections c ON c.id = r.collection_id "
"WHERE r.repo IS NULL AND r.state IN ('super-draft', 'active') "
" AND c.project_id = ?",
(prow["id"],),
).fetchall()
for r in super_drafts:
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?)
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
last_commit_at = excluded.last_commit_at
""",
(r["slug"], meta_main_sha, meta_main_ts),
)
# Mark previously-known edit branches that disappeared as deleted per
# §11.5 / §12. Keep the row so chat history survives the branch's
# deletion in Gitea.
+5 -4
View File
@@ -293,20 +293,21 @@ async def _delete_branch_via_bot(
(we leave the branch row in place a subsequent reconciler sweep
will reconcile or the operator can intervene)."""
rfc = db.conn().execute(
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT state, repo, collection_id FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if rfc is None:
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
return False
if not rfc["repo"]:
repo = projects_mod.default_content_repo(config)
# §22/G-15: the edit/graduation branch lives on the entry's COLLECTION's
# project content_repo, not the deployment default.
owner, repo, _ = projects_mod.entry_location(config, rfc["collection_id"], slug)
if not repo:
log.warning(
"hygiene: default project has no content_repo; skipping branch delete for %s/%s",
"hygiene: no content_repo resolved; skipping branch delete for %s/%s",
slug, branch,
)
return False
owner = config.gitea_org
elif "/" in rfc["repo"]:
owner, repo = rfc["repo"].split("/", 1)
else:
+31
View File
@@ -201,6 +201,37 @@ def content_repo(project_id: str) -> str | None:
return row["content_repo"] if row and row["content_repo"] else None
def content_repo_for_collection(collection_id: str) -> str | None:
"""The content repo a collection's entries live in (§22 three-tier write
path, G-15): collection project content_repo. None if the collection or
its project is unknown/unset. The per-collection successor to
`default_content_repo` for the WRITE path an entry in a non-default
project must read/write that project's repo, not the deployment default."""
from . import collections as collections_mod
pid = collections_mod.project_of_collection(collection_id)
return content_repo(pid) if pid else None
def entry_location(config: Config, collection_id: str, slug: str) -> tuple[str, str, str]:
"""The git location `(gitea_org, content_repo, md_path)` of an entry, resolved
from its collection (§22 three-tier, G-15).
Repo: the collection's project content_repo, falling back to the deployment
default project's repo when the collection (or its project) is unknown — so a
legacy/single-corpus entry still resolves to a usable location rather than an
empty repo. Path: `<subfolder>/rfcs/<slug>.md`, or `rfcs/<slug>.md` at the
repo root for a default (subfolder-less) collection.
This is the single resolver the branch/edit/body/metadata/graduation write
paths share, replacing the hardcoded `default_content_repo` + `rfcs/<slug>.md`.
"""
from . import collections as collections_mod
repo = content_repo_for_collection(collection_id) or (default_content_repo(config) or "")
sub = collections_mod.subfolder_of(collection_id)
rfcs_dir = f"{sub}/rfcs" if sub else "rfcs"
return config.gitea_org, repo, f"{rfcs_dir}/{slug}.md"
def project_initial_state(project_id: str) -> str:
"""§22.4b landing state for new entries in a project's default collection
(the per-corpus field moved down to the collection in migration 029).
+12 -5
View File
@@ -80,10 +80,17 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
payload = {}
repo_full = (payload.get("repository") or {}).get("full_name") or ""
registry_full = f"{config.gitea_org}/{config.registry_repo}"
content_repo = projects_mod.default_content_repo(config)
if not content_repo:
log.warning("webhook: default project content_repo is unknown; corpus refresh skipped")
content_full = f"{config.gitea_org}/{content_repo}" if content_repo else None
# §22/G-15: a corpus push can land on ANY project's content_repo, not
# just the default — recognise the full set so a non-default project's
# push triggers the (multi-project) corpus/branch/PR refresh.
content_fulls = {
f"{config.gitea_org}/{r['content_repo']}"
for r in db.conn().execute(
"SELECT content_repo FROM projects "
"WHERE content_repo IS NOT NULL AND content_repo != ''")
}
if not content_fulls:
log.warning("webhook: no project content_repo is known; corpus refresh skipped")
try:
if repo_full == registry_full:
# §22.2: a registry-repo push re-mirrors the projects table.
@@ -94,7 +101,7 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
await registry_mod.refresh_registry(config, gitea)
except registry_mod.RegistryError:
log.exception("registry webhook: invalid projects.yaml; keeping last-good")
elif content_full and (repo_full == content_full or not repo_full):
elif content_fulls and (repo_full in content_fulls or not repo_full):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
@@ -0,0 +1,192 @@
"""G-15 — the branch/body subsystem is three-tier (project/collection) aware.
Before G-15 the branch-body GET resolved every meta-resident entry to the
default project's content repo at `rfcs/<slug>.md`, so an entry in a named
collection (subfolder) or a non-default project's repo rendered a BLANK
canonical body and its edit/PR/body-write paths hit the wrong file. These tests
seed entries outside the default collection and assert the collection-scoped
body-read routes (and the now-collection-aware slug-only routes) read the
correct repo + subfolder.
"""
from __future__ import annotations
import asyncio
from app import cache as cache_mod, db, gitea as gitea_mod
from app.config import load_config
from fastapi.testclient import TestClient # noqa: E402
from test_propose_vertical import ( # noqa: E402,F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _entry_md(slug, title, state="active"):
return f"---\nslug: {slug}\ntitle: {title}\nstate: {state}\n---\nthe canonical body\n"
def _add_features_collection():
"""A named 'features' collection (subfolder 'features') under the seeded
default project same content repo ('meta'), distinct subfolder."""
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
"initial_state, visibility, name, created_at, updated_at) VALUES "
"('features','default','document','features','super-draft','public','Features', "
"datetime('now'), datetime('now'))")
def _add_distinct_project():
"""A second project with its OWN content repo + a default (root) collection
the live OHM dogfood shape (rfc-app project at rfc-app-content)."""
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES ('rfc-app','RFC App','rfc-app-content','public', datetime('now'))")
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
"initial_state, visibility, name, created_at, updated_at) VALUES "
"('rfc-app','rfc-app','document','','super-draft','public','RFC App', "
"datetime('now'), datetime('now'))")
def _mirror():
cfg = load_config()
asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
# --- named collection (subfolder) under the default project -------------------
def test_branch_view_named_collection_reads_subfolder(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection()
fake.files[("wiggleverse", "meta", "main", "features/rfcs/feat.md")] = {
"content": _entry_md("feat", "Feature Entry"), "sha": "sf"}
_mirror()
# cached_rfcs is keyed by the named collection.
assert db.conn().execute(
"SELECT collection_id FROM cached_rfcs WHERE slug='feat'"
).fetchone()["collection_id"] == "features"
# Collection-scoped branch view renders the body (was blank pre-G-15).
r = client.get(
"/api/projects/default/collections/features/rfcs/feat/branches/main")
assert r.status_code == 200, r.text
assert r.json()["body"] == "the canonical body\n"
# The slug-only legacy route is now collection-aware too: it resolves
# the entry's own subfolder via the cached row, so it also renders.
r2 = client.get("/api/rfcs/feat/branches/main")
assert r2.status_code == 200, r2.text
assert r2.json()["body"] == "the canonical body\n"
def test_main_view_named_collection_scoped_route(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection()
fake.files[("wiggleverse", "meta", "main", "features/rfcs/feat.md")] = {
"content": _entry_md("feat", "Feature Entry"), "sha": "sf"}
_mirror()
r = client.get("/api/projects/default/collections/features/rfcs/feat/main")
assert r.status_code == 200, r.text
assert r.json()["title"] == "Feature Entry"
# --- non-default project with a DISTINCT content repo (the OHM dogfood) -------
def test_branch_view_distinct_project_repo(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_distinct_project()
fake.files[("wiggleverse", "rfc-app-content", "main", "rfcs/scoped.md")] = {
"content": _entry_md("scoped", "Scoped Admin IA"), "sha": "s1"}
_mirror()
assert db.conn().execute(
"SELECT collection_id FROM cached_rfcs WHERE slug='scoped'"
).fetchone()["collection_id"] == "rfc-app"
# Scoped read resolves the entry's OWN content repo (rfc-app-content).
r = client.get(
"/api/projects/rfc-app/collections/rfc-app/rfcs/scoped/branches/main")
assert r.status_code == 200, r.text
assert r.json()["body"] == "the canonical body\n"
# And the slug-only route resolves the right repo via the cached row.
r2 = client.get("/api/rfcs/scoped/branches/main")
assert r2.status_code == 200, r2.text
assert r2.json()["body"] == "the canonical body\n"
# --- guards + regression ------------------------------------------------------
def test_scoped_branch_route_404_for_collection_outside_project(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get(
"/api/projects/default/collections/nope/rfcs/x/branches/main")
assert r.status_code == 404
def _seed_super_draft_in_collection(fake, *, slug, collection_id, subfolder, owners):
import json as _json
import yaml
md_path = f"{subfolder}/rfcs/{slug}.md" if subfolder else f"rfcs/{slug}.md"
fm = {"slug": slug, "title": slug.title(), "state": "super-draft", "id": None,
"repo": None, "proposed_by": owners[0], "proposed_at": "2026-05-23",
"graduated_at": None, "graduated_by": None,
"owners": owners, "arbiters": owners[:1], "tags": []}
body = "the body\n"
text = f"---\n{yaml.safe_dump(fm, sort_keys=False).rstrip()}\n---\n\n{body}"
sha = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", md_path)] = {"content": text, "sha": sha}
db.conn().execute(
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, rfc_id, repo, "
"proposed_by, proposed_at, owners_json, arbiters_json, tags_json, body, "
"body_sha, collection_id, last_main_commit_at, last_entry_commit_at) "
"VALUES (?,?, 'super-draft', NULL, NULL, ?, '2026-05-23', ?, ?, '[]', ?, ?, ?, "
"datetime('now'), datetime('now'))",
(slug, slug.title(), owners[0], _json.dumps(owners), _json.dumps(owners[:1]),
body, sha, collection_id))
def test_graduate_in_named_collection_writes_to_subfolder(app_with_fake_gitea):
"""G-15 write path: graduating a super-draft that lives in a named
collection flips the entry in that collection's `<subfolder>/rfcs/<slug>.md`,
not the default `rfcs/<slug>.md`."""
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection()
provision_user_row(user_id=1, login="ben", role="owner")
_seed_super_draft_in_collection(
fake, slug="gradme", collection_id="features", subfolder="features",
owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@x")
r = client.post("/api/rfcs/gradme/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "owners": ["ben"]})
assert r.status_code == 200, r.text
# The flip landed in the collection's subfolder, not the repo root.
sc = fake.files.get(
("wiggleverse", "meta", "main", "features/rfcs/gradme.meta.yaml"))
assert sc is not None, "sidecar not written under the collection subfolder"
import yaml as _yaml
assert _yaml.safe_load(sc["content"])["state"] == "active"
# Nothing was written to the default repo-root path.
assert ("wiggleverse", "meta", "main", "rfcs/gradme.meta.yaml") not in fake.files
def test_default_collection_entry_still_renders(app_with_fake_gitea):
"""Regression: the default-collection path (repo root `rfcs/<slug>.md` in
the default content repo) is unchanged by the G-15 resolution."""
app, fake = app_with_fake_gitea
with TestClient(app) as client:
fake.files[("wiggleverse", "meta", "main", "rfcs/base.md")] = {
"content": _entry_md("base", "Baseline"), "sha": "sb"}
_mirror()
r = client.get("/api/rfcs/base/branches/main")
assert r.status_code == 200, r.text
assert r.json()["body"] == "the canonical body\n"
r2 = client.get("/api/projects/default/collections/default/rfcs/base/branches/main")
assert r2.status_code == 200, r2.text
assert r2.json()["body"] == "the canonical body\n"
+108
View File
@@ -0,0 +1,108 @@
"""G-15 — the §22 three-tier write-path resolver.
`projects.content_repo_for_collection` and `projects.entry_location` resolve an
entry's git location (org, content_repo, md_path) from its *collection*
(collection project content_repo, plus the collection subfolder) instead of
the deployment default. This is the keystone the branch/edit/body/graduation
write paths share so an entry outside the default project's default collection
reads/writes the correct file.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from app import collections as collections_mod, db, projects as projects_mod
from app.config import Config
def _db() -> Config:
cfg = Config(
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
registry_repo="registry", oauth_client_id="x",
oauth_client_secret="x", app_url="x", secret_key="x",
database_path=Path(tempfile.mkdtemp(prefix="g15loc-")) / "t.db",
owner_gitea_login="x", webhook_secret="x",
)
db.run_migrations(cfg)
if db._CONN is not None:
db._CONN.close()
db._CONN = None
db.init(cfg)
return cfg
def _seed():
# Default project (its content_repo is the deployment default) + a second
# project with a DISTINCT content_repo, each with a default + a named
# (subfolder) collection.
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES ('default','Default','meta','public', datetime('now'))")
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES ('rfc-app','RFC App','rfc-app-content','public', datetime('now'))")
rows = [
("default", "default", ""),
("features", "default", "features"),
("rfc-app", "rfc-app", ""),
("specs", "rfc-app", "specs"),
]
for cid, pid, sub in rows:
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
"initial_state, visibility, created_at, updated_at) VALUES "
"(?,?, 'document', ?, 'super-draft','public', datetime('now'), datetime('now'))",
(cid, pid, sub))
def test_content_repo_for_collection_resolves_per_project():
_db()
_seed()
# Default project's collections → the default content repo.
assert projects_mod.content_repo_for_collection("default") == "meta"
assert projects_mod.content_repo_for_collection("features") == "meta"
# The second project's collections → its own content repo.
assert projects_mod.content_repo_for_collection("rfc-app") == "rfc-app-content"
assert projects_mod.content_repo_for_collection("specs") == "rfc-app-content"
def test_content_repo_for_collection_unknown_is_none():
_db()
_seed()
assert projects_mod.content_repo_for_collection("nope") is None
def test_entry_location_default_collection_repo_root():
cfg = _db()
_seed()
org, repo, path = projects_mod.entry_location(cfg, "default", "alpha")
assert (org, repo, path) == ("wiggleverse", "meta", "rfcs/alpha.md")
def test_entry_location_named_collection_uses_subfolder():
cfg = _db()
_seed()
org, repo, path = projects_mod.entry_location(cfg, "features", "beta")
assert (org, repo, path) == ("wiggleverse", "meta", "features/rfcs/beta.md")
def test_entry_location_other_project_distinct_repo():
cfg = _db()
_seed()
# Named collection in a non-default project: distinct repo AND subfolder.
org, repo, path = projects_mod.entry_location(cfg, "specs", "gamma")
assert (org, repo, path) == ("wiggleverse", "rfc-app-content", "specs/rfcs/gamma.md")
# Default collection of the non-default project: distinct repo, repo root.
org, repo, path = projects_mod.entry_location(cfg, "rfc-app", "delta")
assert (org, repo, path) == ("wiggleverse", "rfc-app-content", "rfcs/delta.md")
def test_entry_location_unknown_collection_falls_back_to_default_repo():
cfg = _db()
_seed()
# An entry whose collection_id is missing/unknown must still resolve to a
# usable location (the deployment default repo, repo root) rather than an
# empty repo — the legacy single-corpus behaviour.
org, repo, path = projects_mod.entry_location(cfg, "nope", "epsilon")
assert (org, repo, path) == ("wiggleverse", "meta", "rfcs/epsilon.md")
+53
View File
@@ -0,0 +1,53 @@
import { test, expect } from './lib/fixtures.js'
import { signIn, OWNER_EMAIL } from './lib/auth.js'
import { dismissCookies } from './lib/ui.js'
// §8.12 Option B — asking-while-reading on the canonical `main` view.
//
// Before this fix, the AI "Ask" affordance had no chat surface on main (the
// human-discussion panel renders there, not the chat panel), so asking from the
// reading view silently did nothing. Option B transparently cuts an edit branch,
// navigates to it, and runs the question there.
//
// This spec asserts the model-independent core: the branch cut + navigation
// (old code did NOTHING here — the silent no-op), and that the question turn
// actually FIRED against the branch. The turn's outcome is environment-specific
// — PPE/prod (a model is configured) streams an answer and the optimistic
// question persists; the Tier-1 stack has no AI provider, so the turn errors
// with "Chat failed". Either outcome proves the turn fired; we match both so the
// one spec passes in both environments. The quote-passthrough + answer rendering
// are covered deterministically by the RFCView unit tests (model mocked).
//
// Driven through the prompt bar, which shares handlePrompt → handleMainAsk with
// the selection tooltip's onAsk. Runs against the faceted `bdd` collection entry
// (collection-scoped — the same three-tier path G-15 hardened).
const ENTRY = '/p/ohm/c/bdd/e/checkout-returning'
test('Ask from the canonical view cuts an edit branch and lands the question in chat', async ({ page }) => {
await signIn(page, OWNER_EMAIL)
await page.goto(ENTRY)
await dismissCookies(page)
// We start on the canonical (main) view: read-only, no chat surface — the
// discuss-mode banner is the canonical-view tell, and the URL has no branch.
await expect(page.locator('.discuss-mode-banner')).toContainText('read-only')
expect(new URL(page.url()).searchParams.get('branch')).toBeNull()
// Ask a question from the prompt bar.
const question = `What problem does this entry solve? (${Date.now()})`
await page.locator('.prompt-input').fill(question)
await page.getByRole('button', { name: 'Ask', exact: true }).click()
// Option B: an edit branch is cut and we navigate onto it (no silent no-op).
await expect(async () => {
expect(new URL(page.url()).searchParams.get('branch')).toMatch(/^edit/)
}).toPass({ timeout: 30_000 })
// The question turn fired against the new branch: either the optimistic
// question is showing (model answered / answering) or the turn surfaced a
// chat error (no AI provider in Tier-1). Both prove it ran — the pre-fix
// silent no-op showed neither.
await expect(
page.getByText(question).or(page.getByText(/Chat failed|No AI providers/i)),
).toBeVisible({ timeout: 30_000 })
})
+15 -6
View File
@@ -43,15 +43,22 @@ test('SLICE-4: edit one entry\'s priority from the detail panel', async ({ page
const panel = page.locator('.metadata-fields-panel')
await expect(panel).toBeVisible()
// Seeded at P1; change to P2 and save (direct sidecar commit, D7).
await expect(panel.locator('#mf-priority')).toHaveValue('P1')
await panel.locator('#mf-priority').selectOption('P2')
const select = panel.locator('#mf-priority')
// Value-agnostic so the test is idempotent across retries and re-runs: a
// prior (committed) edit may have already moved it off the P1 seed. Pick a
// target distinct from the current value so Save is a real change.
const current = await select.inputValue()
const target = current === 'P2' ? 'P1' : 'P2'
await select.selectOption(target)
await panel.getByRole('button', { name: 'Save' }).click()
await expect(panel.getByText('Saved')).toBeVisible()
// The save is a real git sidecar commit via the bot (D7); over the deployed
// edge the round trip can take tens of seconds — wait generously for the
// confirmation rather than the default expect timeout.
await expect(panel.getByText('Saved')).toBeVisible({ timeout: 60_000 })
// Persisted across a reload (read back from the committed sidecar).
await page.reload()
await expect(page.locator('.metadata-fields-panel #mf-priority')).toHaveValue('P2')
await expect(page.locator('.metadata-fields-panel #mf-priority')).toHaveValue(target)
})
// SLICE-5 (PUC-2) — multi-select + bulk action bar, one commit (authed).
@@ -71,8 +78,10 @@ test('SLICE-5: bulk-set priority on multiple selected entries', async ({ page })
await expect(bar.getByText('2 selected')).toBeVisible()
// Set priority P1 across both → one commit; a toast reports the result.
// Like SLICE-4, the bulk write is a real git commit via the bot; allow the
// deployed-edge round trip generous time before the result toast appears.
await bar.getByLabel('Set Priority').selectOption('P1')
await expect(page.getByText(/2 updated/)).toBeVisible()
await expect(page.getByText(/2 updated/)).toBeVisible({ timeout: 60_000 })
// The change is reflected server-side: the Priority P1 facet now counts the
// two newly-updated entries plus the pre-existing P1 (checkout-returning was
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.52.1",
"version": "0.54.0",
"type": "module",
"scripts": {
"dev": "vite",
+16
View File
@@ -2687,3 +2687,19 @@ select:focus-visible,
align-items: flex-start;
gap: 12px;
}
/* 404 / not-found page (client-side, for unmatched routes) */
.not-found {
max-width: 32rem;
margin: 4rem auto;
padding: 0 1.5rem;
text-align: center;
}
.not-found-code {
font-size: 3.5rem;
line-height: 1;
margin: 0 0 0.5rem;
color: var(--c-gray-900, #111);
}
.not-found-message { color: var(--c-gray-700, #444); margin: 0 0 1.25rem; }
.not-found-actions a { color: var(--c-gray-900, #111); text-decoration: underline; }
+6 -3
View File
@@ -7,6 +7,7 @@ import { brandTitle } from './lib/brand'
import { entryPath, proposalPath, DEFAULT_COLLECTION } from './lib/entryPaths'
import { useDeployment } from './context/DeploymentProvider'
import ProjectLayout from './components/ProjectLayout.jsx'
import NotFound from './components/NotFound.jsx'
import Directory from './components/Directory.jsx'
import ProjectSwitcher from './components/ProjectSwitcher.jsx'
import Catalog from './components/Catalog.jsx'
@@ -381,13 +382,14 @@ export default function App() {
<Route path="c/:collectionId/e/:slug" element={<RFCView viewer={viewer} />} />
<Route path="c/:collectionId/e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
<Route path="c/:collectionId/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</ProjectLayout>
} />
{/* Any other path (incl. the retired bare-slug corpus URLs that
somehow reach the SPA) lands on the deployment landing. */}
<Route path="*" element={<Navigate to="/" replace />} />
{/* Any unmatched path is a real 404 surface it rather than
silently bouncing home, so a bad/typo'd URL is visible. */}
<Route path="*" element={<NotFound />} />
</Routes>
</div>
{(proposeOpen || proposeParam != null) && viewer && (
@@ -507,6 +509,7 @@ function DocsWithSidebar({ viewer }) {
client-side redirect to the first configured spec. */}
<Route path="specs" element={<DocsSpecsIndex />} />
<Route path="specs/:name" element={<DocsSpec />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</main>
+40
View File
@@ -0,0 +1,40 @@
// §22/G-15 — getRFCMain/getBranch build collection-scoped URLs when a project +
// collection are supplied (so an entry outside the default collection reads its
// own content repo / subfolder), and fall back to the slug-only routes otherwise.
import { describe, it, expect, vi, afterEach } from 'vitest'
import { getRFCMain, getBranch } from './api.js'
function mockFetch() {
const fn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }))
global.fetch = fn
return fn
}
afterEach(() => { vi.restoreAllMocks() })
describe('G-15 collection-scoped branch/body URLs', () => {
it('getRFCMain scopes to project+collection when both are given', async () => {
const f = mockFetch()
await getRFCMain('login', 'rfc-app', 'specs')
expect(f).toHaveBeenCalledWith('/api/projects/rfc-app/collections/specs/rfcs/login/main')
})
it('getRFCMain falls back to the slug-only route without a collection', async () => {
const f = mockFetch()
await getRFCMain('login')
expect(f).toHaveBeenCalledWith('/api/rfcs/login/main')
})
it('getBranch scopes to project+collection and encodes the branch', async () => {
const f = mockFetch()
await getBranch('login', 'edit/foo', 'rfc-app', 'specs')
expect(f).toHaveBeenCalledWith(
'/api/projects/rfc-app/collections/specs/rfcs/login/branches/edit%2Ffoo')
})
it('getBranch falls back to the slug-only route without a collection', async () => {
const f = mockFetch()
await getBranch('login', 'main')
expect(f).toHaveBeenCalledWith('/api/rfcs/login/branches/main')
})
})
+18 -2
View File
@@ -448,11 +448,27 @@ export async function listModels(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/models`))
}
export async function getRFCMain(slug) {
export async function getRFCMain(slug, projectId, collectionId) {
// §22/G-15: when the caller knows the entry's project + collection, read the
// collection-scoped route so a slug that exists in two collections resolves
// unambiguously and the entry's own content repo / subfolder is used. The
// bare-slug form stays for back-compat (default-collection callers).
if (projectId && collectionId) {
return jsonOrThrow(await fetch(
`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/main`
))
}
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`))
}
export async function getBranch(slug, branch) {
export async function getBranch(slug, branch, projectId, collectionId) {
// §22/G-15: collection-scoped branch-body read (the canonical-body GET); see
// getRFCMain for the rationale. Falls back to the slug-only route.
if (projectId && collectionId) {
return jsonOrThrow(await fetch(
`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
))
}
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
))
+4 -1
View File
@@ -29,6 +29,7 @@ import {
} from '../api.js'
import { EVENTS, track } from '../lib/analytics.js'
import { entryPath, useProjectId } from '../lib/entryPaths'
import NotFound from './NotFound.jsx'
// v0.17.0 roadmap item #16. The max length the backend enforces
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
@@ -59,7 +60,8 @@ export default function Admin({ viewer }) {
{tabs.map(t => (
<li key={t.path}>
<NavLink
to={t.path}
to={`/admin/${t.path}`}
end
className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
>
{t.label}
@@ -81,6 +83,7 @@ export default function Admin({ viewer }) {
{isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
<Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} />
<Route path="*" element={<NotFound message="That admin page doesn't exist." />} />
</Routes>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
// Client-side 404 for routes that don't match. An SPA serves index.html
// for every non-API path (HTTP 200), so an unknown URL would otherwise
// render blank or, worse, silently bounce home. This surfaces a clear
// "page not found" instead. Used by the top-level router and by the
// nested /admin, /p/:projectId, and /docs route groups so an invalid
// subpath (e.g. /admin/users/allowlist/graduation) lands here too.
import { Link } from 'react-router-dom'
export default function NotFound({ message }) {
return (
<div className="not-found" role="alert">
<h1 className="not-found-code">404</h1>
<p className="not-found-message">
{message || "We couldn't find that page."}
</p>
<p className="not-found-actions">
<Link to="/">Return to the catalog</Link>
</p>
</div>
)
}
+70 -19
View File
@@ -93,6 +93,15 @@ export default function RFCView({ viewer }) {
// surface to expose.
const editorRef = useRef(null)
const originalSourceLinesRef = useRef([])
// §8.12 Option B asking-while-reading on `main`. When Ask is invoked from
// the canonical view we transparently cut an edit branch, navigate to it, and
// run the question there once its view (and main_thread_id) loads. The pending
// question is stashed here and fired by the pending-ask effect below we
// can't push optimistic chat messages before branchView.main_thread_id exists.
const pendingAskRef = useRef(null) // { text, quote, branch } | null
// Live ref to submitChatTurn so the (intentionally narrow-dep) message-load
// effect can fire the stashed §8.12 main-view ask with the latest closure.
const submitChatTurnRef = useRef(null)
const [editorContent, setEditorContent] = useState('')
// Mirror of the live CM6 doc for the Contribute-mode preview pane,
// debounced so the preview doesn't re-render on every keystroke.
@@ -221,8 +230,8 @@ export default function RFCView({ viewer }) {
setSelection(null)
setMode('discuss')
getRFCMain(slug).then(setMainView).catch(err => setError(err.message))
getBranch(slug, branchParam)
getRFCMain(slug, pid, cid).then(setMainView).catch(err => setError(err.message))
getBranch(slug, branchParam, pid, cid)
.then(view => {
setBranchView(view)
setEditorContent(view.body || '')
@@ -231,13 +240,27 @@ export default function RFCView({ viewer }) {
setChanges(view.changes || [])
})
.catch(err => setError(err.message))
}, [slug, branchParam, entry])
// §22/G-15: pid+cid scope the body reads; re-run if the collection changes.
}, [slug, branchParam, entry, pid, cid])
// Load chat messages whenever the branch's main thread id resolves.
useEffect(() => {
if (!branchView?.main_thread_id) return
loadAllMessages(slug, branchParam, branchView.threads).then(setMessages)
}, [branchView?.main_thread_id, slug, branchParam])
loadAllMessages(slug, branchParam, branchView.threads).then(loaded => {
setMessages(loaded)
// §8.12 Option B run the stashed main-view question now that the freshly
// cut branch's messages have loaded. Firing here (rather than in a parallel
// effect) appends the turn AFTER the loaded set, so this late message load
// can't clobber the optimistic chat turn. Land in contribute mode to
// surface any edits the turn proposes (AI chat is an editing activity).
const pending = pendingAskRef.current
if (pending && pending.branch === branchView.branch_name && pending.branch === branchParam) {
pendingAskRef.current = null
setMode('contribute')
submitChatTurnRef.current?.(pending.text, pending.quote)
}
})
}, [branchView?.main_thread_id, branchView?.branch_name, slug, branchParam])
// Selection wiring (§8.12). MarkdownPreview reports {text, coords}
// sourced from window.getSelection(); the SelectionTooltip
@@ -266,7 +289,7 @@ export default function RFCView({ viewer }) {
paragraphCount: manualPending?.paragraphCount || 1,
})
if (!res.noop) {
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
setChanges(fresh.changes || [])
setPreviewContent(fresh.body || '')
@@ -334,8 +357,12 @@ export default function RFCView({ viewer }) {
}, [manualCountdown, flushManualBuffer])
// Start contributing
// Returns the branch the viewer should now be on (the freshly cut branch on
// main, or the current branch on a mode-flip), or null if no branch was cut
// (signed-out login redirect, or the server rejected the cut). §8.12's
// main-view Ask reuses this dispatch and consumes the returned branch name.
const handleStartContributing = useCallback(async () => {
if (!viewer) { window.location.href = '/auth/login'; return }
if (!viewer) { window.location.href = '/auth/login'; return null }
if (branchParam === 'main') {
try {
// §9.5 dispatch: super-drafts cut a meta-repo edit branch via
@@ -344,18 +371,34 @@ export default function RFCView({ viewer }) {
? await startEditBranch(slug)
: await promoteToBranch(slug)
setSearchParams({ branch: branch_name })
return branch_name
} catch (err) {
setError(err.message)
return null
}
return
}
// Non-main: pure mode flip per §8.14.
if (pendingDiscussChanges.length > 0) {
setPendingDiscussChanges([])
}
setMode('contribute')
return branchParam
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams, isSuperDraft])
// §8.12 Option B Ask invoked from the canonical `main` view
// AI chat is an editing activity and only runs on an edit branch, so asking
// while reading transparently cuts one (same dispatch as Start Contributing),
// navigates to it, and stashes the question; the pending-ask effect fires it
// once the new branch's view (and its main_thread_id) has resolved. Degrades
// gracefully: signed-out login; a viewer who can't contribute has the cut
// rejected server-side the error surfaces and we neither navigate nor chat.
const handleMainAsk = useCallback(async (text, quote) => {
if (!viewer) { window.location.href = '/auth/login'; return }
const branch = await handleStartContributing()
if (!branch || branch === 'main') return // cut failed setError already ran
pendingAskRef.current = { text, quote: quote || null, branch }
}, [viewer, handleStartContributing])
// Submit a chat turn (prompt bar or selection tooltip)
const submitChatTurn = useCallback(async (text, quote) => {
if (!branchView?.main_thread_id || isStreaming) return
@@ -408,7 +451,7 @@ export default function RFCView({ viewer }) {
))
}
// Re-pull authoritative state: changes have been materialized server-side.
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setChanges(fresh.changes || [])
// If we're in discuss mode and the new turn produced pending AI changes,
// surface them as discuss-mode buffered count.
@@ -426,16 +469,24 @@ export default function RFCView({ viewer }) {
}
}, [slug, branchParam, branchView?.main_thread_id, isStreaming, viewer, selectedModel, mode])
// Keep submitChatTurnRef pointed at the latest submitChatTurn so the
// message-load effect's §8.12 main-view ask uses the current branch closure.
useEffect(() => { submitChatTurnRef.current = submitChatTurn }, [submitChatTurn])
const handlePrompt = useCallback((text, sel) => {
const quote = sel?.text || null
// On the canonical view there is no chat surface; Ask cuts an edit branch
// and runs the question there (§8.12 Option B). On a branch, ask directly.
if (branchParam === 'main') { handleMainAsk(text, quote); return }
submitChatTurn(text, quote)
}, [submitChatTurn])
}, [branchParam, handleMainAsk, submitChatTurn])
const handleTooltipAsk = useCallback(async (textOrNull, quote) => {
if (textOrNull === null) { setSelection(null); return }
setSelection(null)
if (branchParam === 'main') { await handleMainAsk(textOrNull, quote); return }
await submitChatTurn(textOrNull, quote)
}, [submitChatTurn])
}, [branchParam, handleMainAsk, submitChatTurn])
const handleTooltipFlag = useCallback(async (label, quote) => {
if (!viewer) { window.location.href = '/auth/login'; return }
@@ -447,7 +498,7 @@ export default function RFCView({ viewer }) {
anchor_payload: { quote },
label,
})
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
} catch (err) {
@@ -463,7 +514,7 @@ export default function RFCView({ viewer }) {
// body. The proper tracked-changes overlay lives in Phase 3's
// preview pane; with CM6 as the source editor there is no HTML
// surface to inject `<span class="tracked-*">` into.
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
setChanges(fresh.changes || [])
setEditorContent(fresh.body || '')
@@ -477,7 +528,7 @@ export default function RFCView({ viewer }) {
const handleDecline = useCallback(async (changeId) => {
try {
await apiDecline(slug, branchParam, changeId)
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setChanges(fresh.changes || [])
} catch (err) {
setError(err.message)
@@ -487,7 +538,7 @@ export default function RFCView({ viewer }) {
const handleReask = useCallback(async (changeId) => {
try {
await reaskChange(slug, branchParam, changeId)
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
setChanges(fresh.changes || [])
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
@@ -499,7 +550,7 @@ export default function RFCView({ viewer }) {
const handleResolveThread = useCallback(async (threadId) => {
try {
await resolveThread(slug, branchParam, threadId)
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
} catch (err) {
@@ -977,7 +1028,7 @@ export default function RFCView({ viewer }) {
current={branchView.visibility}
onClose={() => setShowVisibility(false)}
onSaved={async () => {
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
setShowVisibility(false)
}}
@@ -993,7 +1044,7 @@ export default function RFCView({ viewer }) {
setShowGraduateDialog(false)
// The catalog row and the RFC view now reflect `active`.
getRFC(pid, slug, cid).then(setEntry).catch(() => {})
getRFCMain(slug).then(setMainView).catch(() => {})
getRFCMain(slug, pid, cid).then(setMainView).catch(() => {})
}}
/>
)}
@@ -1016,7 +1067,7 @@ export default function RFCView({ viewer }) {
setShowMetadataPane(false)
// Refresh main view so the new open PR surfaces in the
// breadcrumb meta count immediately.
getRFCMain(slug).then(setMainView).catch(() => {})
getRFCMain(slug, pid, cid).then(setMainView).catch(() => {})
navigate(entryPrPath(pid, slug, prNumber))
}}
/>
+220
View File
@@ -0,0 +1,220 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
// §8.12 Option B asking-while-reading on the canonical `main` view. The AI
// "Ask" affordance (selection tooltip + prompt bar) has no chat surface on
// main (RFCDiscussionPanel renders there, not ChatPanel). These tests cover the
// transparent branch-cut: Ask on main cuts an edit branch, navigates to it, and
// runs the question there; Ask on a branch is unchanged; a rejected cut degrades
// gracefully; Flag on main still opens a discussion thread.
const getRFC = vi.fn()
const getRFCMain = vi.fn()
const getBranch = vi.fn()
const listModels = vi.fn()
const getCollection = vi.fn()
const getThreadMessages = vi.fn()
const promoteToBranch = vi.fn()
const startEditBranch = vi.fn()
const streamChatTurn = vi.fn()
const createThread = vi.fn()
vi.mock('../api', () => ({
getRFC: (...a) => getRFC(...a),
getRFCMain: (...a) => getRFCMain(...a),
getBranch: (...a) => getBranch(...a),
listModels: (...a) => listModels(...a),
getCollection: (...a) => getCollection(...a),
getThreadMessages: (...a) => getThreadMessages(...a),
promoteToBranch: (...a) => promoteToBranch(...a),
startEditBranch: (...a) => startEditBranch(...a),
streamChatTurn: (...a) => streamChatTurn(...a),
createThread: (...a) => createThread(...a),
acceptChange: vi.fn(), declineChange: vi.fn(), editMetadata: vi.fn(),
manualFlush: vi.fn(), reaskChange: vi.fn(), resolveThread: vi.fn(),
setBranchVisibility: vi.fn(), claimOwnership: vi.fn(),
retireRFC: vi.fn(), unretireRFC: vi.fn(),
}))
vi.mock('../lib/entryPaths', () => ({
entryPath: () => '/p/ohm/c/rfc-app/e/the-entry',
entryPrPath: () => '/pr',
useProjectId: () => 'ohm',
useCollectionId: () => 'rfc-app',
}))
vi.mock('../lib/analytics', () => ({ EVENTS: { RFC_VIEWED: 'rfc_viewed' }, track: vi.fn() }))
// Stub the heavy children; expose just the callbacks/data the flow needs.
vi.mock('./MarkdownSourceEditor.jsx', () => ({ default: () => null }))
vi.mock('./MarkdownPreview.jsx', () => ({ default: () => <div data-testid="preview" /> }))
vi.mock('./RFCDiscussionPanel.jsx', () => ({ default: () => <div data-testid="discussion-panel" /> }))
vi.mock('./ChatPanel.jsx', () => ({
default: ({ messages }) => (
<div data-testid="chat-panel">
{(messages || []).map((m, i) => (
<div key={i} data-role={m.role}>{m.text}</div>
))}
</div>
),
}))
vi.mock('./SelectionTooltip.jsx', () => ({
default: ({ onAsk, onFlag, disabled }) => (
<div data-testid="tooltip">
<button data-testid="tooltip-ask" disabled={disabled}
onClick={() => onAsk('Why this approach?', 'the selected quote')}>ask</button>
<button data-testid="tooltip-flag"
onClick={() => onFlag('confusing', 'the selected quote')}>flag</button>
</div>
),
}))
vi.mock('./PromptBar.jsx', () => ({
default: ({ onSubmit, disabled }) => (
<button data-testid="prompt-submit" disabled={disabled}
onClick={() => onSubmit('Why this approach?', { text: 'the selected quote' })}>send</button>
),
}))
vi.mock('./ChangePanel.jsx', () => ({ default: () => null, diffWords: () => [] }))
vi.mock('./PRModal.jsx', () => ({ default: () => null }))
vi.mock('./GraduateDialog.jsx', () => ({ default: () => null }))
vi.mock('./InvitationsModal.jsx', () => ({ default: () => null }))
vi.mock('./MetadataFieldsPanel.jsx', () => ({ default: () => null }))
import RFCView from './RFCView.jsx'
const ACTIVE_ENTRY = {
id: 'RFC-0001', title: 'The Entry', state: 'active',
owners: [], meta: {}, proposed_use_case: '', tags: [],
}
const MAIN_VIEW = { slug: 'the-entry', branches: [], open_prs: [] }
function branchPayload(branch, { main_thread_id = 't-1' } = {}) {
return {
slug: 'the-entry', title: 'The Entry', branch_name: branch, body: '# Body\n',
body_sha: 'abc', main_thread_id, threads: [], changes: [],
visibility: {}, grants: [], creator: 'me',
capabilities: { can_contribute: true, can_change_branch_settings: true },
}
}
const VIEWER = { gitea_login: 'me', role: 'contributor' }
function renderView(viewer = VIEWER, initialBranch = null) {
const path = initialBranch ? `/e/the-entry?branch=${initialBranch}` : '/e/the-entry'
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/e/:slug" element={<RFCView viewer={viewer} />} />
</Routes>
</MemoryRouter>,
)
}
describe('RFCView — §8.12 main-view Ask (Option B)', () => {
beforeEach(() => {
vi.clearAllMocks()
getRFC.mockResolvedValue(ACTIVE_ENTRY)
getRFCMain.mockResolvedValue(MAIN_VIEW)
listModels.mockResolvedValue({ models: [{ id: 'claude' }], default: 'claude' })
getCollection.mockResolvedValue({ fields: null })
getThreadMessages.mockResolvedValue({ messages: [] })
getBranch.mockImplementation((_slug, branch) => Promise.resolve(branchPayload(branch)))
promoteToBranch.mockResolvedValue({ branch_name: 'edit-me-1' })
startEditBranch.mockResolvedValue({ branch_name: 'edit-the-entry' })
createThread.mockResolvedValue({ thread_id: 1, message_id: 1 })
streamChatTurn.mockImplementation(async (_slug, _branch, _threadId, { text, quote }, cb) => {
cb.onChunk?.('Here is the answer.')
cb.onChanges?.({ message_id: 42 })
cb.onDone?.()
return { assistantId: 42, userMsgId: 41 }
})
})
it('cuts an edit branch and runs the question (with the quote) there', async () => {
renderView()
await screen.findByTestId('discussion-panel') // we start on main
fireEvent.click(screen.getByTestId('prompt-submit'))
// The active-RFC dispatch is promote-to-branch.
await waitFor(() => expect(promoteToBranch).toHaveBeenCalledWith('the-entry'))
// Once the new branch view loads, the turn runs there not on main
// with the selected quote intact.
await waitFor(() => expect(streamChatTurn).toHaveBeenCalled())
const [slug, branch, threadId, payload] = streamChatTurn.mock.calls[0]
expect(slug).toBe('the-entry')
expect(branch).toBe('edit-me-1')
expect(threadId).toBe('t-1')
expect(payload).toMatchObject({ text: 'Why this approach?', quote: 'the selected quote' })
// The chat panel is now mounted (we left main) and shows the streamed answer.
const panel = await screen.findByTestId('chat-panel')
await waitFor(() => expect(panel).toHaveTextContent('Here is the answer.'))
})
it('uses start-edit-branch for a super-draft entry', async () => {
getRFC.mockResolvedValue({ ...ACTIVE_ENTRY, state: 'super-draft' })
getBranch.mockImplementation((_slug, branch) =>
Promise.resolve(branchPayload(branch === 'main' ? 'main' : 'edit-the-entry')))
renderView()
await screen.findByTestId('discussion-panel')
fireEvent.click(screen.getByTestId('tooltip-ask'))
await waitFor(() => expect(startEditBranch).toHaveBeenCalledWith('the-entry'))
expect(promoteToBranch).not.toHaveBeenCalled()
await waitFor(() => expect(streamChatTurn).toHaveBeenCalled())
expect(streamChatTurn.mock.calls[0][1]).toBe('edit-the-entry')
})
it('asking from an existing branch runs directly, without cutting a new one', async () => {
renderView(VIEWER, 'edit-me-1')
await screen.findByTestId('chat-panel')
fireEvent.click(screen.getByTestId('prompt-submit'))
await waitFor(() => expect(streamChatTurn).toHaveBeenCalled())
expect(promoteToBranch).not.toHaveBeenCalled()
expect(startEditBranch).not.toHaveBeenCalled()
expect(streamChatTurn.mock.calls[0][1]).toBe('edit-me-1')
})
it('surfaces an error and does not chat when the branch cut is rejected', async () => {
promoteToBranch.mockRejectedValue(new Error('not a contributor'))
renderView()
await screen.findByTestId('discussion-panel')
fireEvent.click(screen.getByTestId('prompt-submit'))
await waitFor(() => expect(promoteToBranch).toHaveBeenCalled())
// Explicit error path not a silent no-op and no spurious chat turn.
expect(await screen.findByText(/not a contributor/)).toBeInTheDocument()
expect(streamChatTurn).not.toHaveBeenCalled()
})
it('Flag on the canonical view still creates a discussion thread (no branch cut)', async () => {
renderView()
await screen.findByTestId('discussion-panel')
fireEvent.click(screen.getByTestId('tooltip-flag'))
await waitFor(() => expect(createThread).toHaveBeenCalled())
const [, branch, body] = createThread.mock.calls[0]
expect(branch).toBe('main')
expect(body).toMatchObject({ thread_kind: 'flag' })
expect(promoteToBranch).not.toHaveBeenCalled()
expect(streamChatTurn).not.toHaveBeenCalled()
})
it('signed-out viewer gets a read-only path on main — no prompt bar, Ask disabled, no cut', async () => {
renderView(null)
await screen.findByTestId('discussion-panel')
expect(screen.queryByTestId('prompt-submit')).toBeNull()
expect(screen.getByTestId('tooltip-ask')).toBeDisabled()
expect(promoteToBranch).not.toHaveBeenCalled()
expect(startEditBranch).not.toHaveBeenCalled()
})
})
+24
View File
@@ -103,6 +103,17 @@ put_file() {
201 "create $_repo/$_path"
}
# delete_file <repo> <path> — remove the file if it exists (no-op if absent).
delete_file() {
_repo="$1"; _path="$2"
_sha=$(file_sha "$_repo" "$_path")
[ -n "$_sha" ] || { echo "seed-ppe: $_repo/$_path absent, nothing to delete"; return 0; }
echo "seed-ppe: deleting $_repo/$_path"
mutate DELETE "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
"{\"message\":\"reseed: drop sidecar $_path\",\"sha\":\"$_sha\",\"branch\":\"main\"}" \
200 "delete $_repo/$_path"
}
ensure_repo "$REGISTRY_REPO"
ensure_repo "$CONTENT_REPO"
@@ -182,4 +193,17 @@ tags: [search]
Faceted search scenario.
" "$RESEED"
# RESEED also drops any metadata sidecars (<slug>.meta.yaml) left by prior
# SLICE-4/5 edits. A sidecar takes precedence over the .md frontmatter
# (dual-read, §22.4a), so without this a re-run inherits the edited
# priorities (everything ends up P1) and the faceted preconditions drift —
# SLICE-3 expects P0 count = 2. Dropping the sidecars restores the
# frontmatter as the source of truth: checkout-guest=P0, search-facets=P0,
# checkout-returning=P1.
if [ "$RESEED" = "1" ]; then
for _slug in checkout-guest checkout-returning search-facets; do
delete_file "$CONTENT_REPO" "bdd/rfcs/$_slug.meta.yaml"
done
fi
echo "seed-ppe: done. Set REGISTRY_REPO=$REGISTRY_REPO on rfc-app-ppe and redeploy."