Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c691ca7fe3 | |||
| bc60ad97be | |||
| c3efe893c3 | |||
| 9275348c45 | |||
| 242831a4a0 | |||
| f0533dc073 | |||
| f7b93d797c | |||
| 24596842ea | |||
| ff88be2e91 | |||
| 4e7410f90b |
+170
@@ -23,6 +23,176 @@ 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.55.1 — 2026-06-09
|
||||||
|
|
||||||
|
**Patch — Patchwatch dependency security floors (no behavior change).**
|
||||||
|
|
||||||
|
Clears the open Patchwatch advisories on the OHM deployment
|
||||||
|
(`ben.stull/rfc-app` issues #36–#40, affecting `ohm-rfc-app` + `rfc-app-ppe`)
|
||||||
|
by forcing the vulnerable dependencies up to patched versions. No application
|
||||||
|
behavior changes; the full backend (685), frontend (66), and localhost E2E (5)
|
||||||
|
suites pass unchanged.
|
||||||
|
|
||||||
|
- **Backend transitive floors** (`backend/requirements.txt`). `tqdm` and `idna`
|
||||||
|
arrive transitively (no lockfile; loose `>=`), so floor-pins force the
|
||||||
|
resolved environment to the patched versions on the next deploy:
|
||||||
|
- `tqdm>=4.66.3` — GHSA-r7q7-xcjw-qx8q, GHSA-g7vv-2v7x-gj9p, PYSEC-2017-74
|
||||||
|
(issues #37, #39, #40). Resolves to 4.68.2.
|
||||||
|
- `idna>=3.15` — GHSA-65pc-fj4g-8rjx, the CVE-2024-3651 `idna.encode()`
|
||||||
|
bypass (issue #38). Resolves to 3.18.
|
||||||
|
- **Frontend devDependency** (`frontend/package.json`). `vitest ^3.0.0 → ^4.1.0`
|
||||||
|
(resolves to 4.1.8), clearing GHSA-5xrq-8626-4rwp — the Vitest UI server
|
||||||
|
arbitrary file read/execute (issue #36, `node-app` layer). Dev/test-only;
|
||||||
|
not shipped in the runtime image. `package-lock.json` regenerated.
|
||||||
|
|
||||||
|
No upgrade steps — a deployment picks up the patched versions on its next
|
||||||
|
`flotilla-core deploy` (the backend env is reinstalled from `requirements.txt`;
|
||||||
|
the frontend is rebuilt from the regenerated lockfile).
|
||||||
|
|
||||||
|
## 0.55.0 — 2026-06-09
|
||||||
|
|
||||||
|
**Minor — the registry reconcile now prunes projects the registry no longer
|
||||||
|
declares; read the upgrade note.**
|
||||||
|
|
||||||
|
The registry mirror was additive-only: it upserted the projects/collections a
|
||||||
|
deployment's `projects.yaml` declares but never removed ones it had dropped.
|
||||||
|
Re-pinning a deployment to a different registry (or editing `projects.yaml` to
|
||||||
|
remove a project) therefore stranded the old project's collections and its
|
||||||
|
cached entries in the database — dead rows that, at scale, starved writes (a
|
||||||
|
deployment's PPE accumulated ~1,200 orphaned entry rows this way and had to be
|
||||||
|
reset by hand).
|
||||||
|
|
||||||
|
- **Prune-on-reconcile.** `registry.refresh_registry(..., prune=True)` now calls
|
||||||
|
`projects.prune_absent_projects`, which deletes any project absent from the
|
||||||
|
freshly-parsed registry together with its collections and every project-scoped
|
||||||
|
row (entries + their satellites, threads + messages, changes, PRs, stars,
|
||||||
|
watches, …) in one transaction, with FK enforcement off for the multi-table
|
||||||
|
delete and a `PRAGMA foreign_key_check` backstop that rolls the whole thing
|
||||||
|
back rather than leave a dangling reference.
|
||||||
|
- **Whole-project granularity, and only the default-safe triggers.** A project
|
||||||
|
still present in the registry is never touched here (pruning individual entries
|
||||||
|
within a present project belongs to the best-effort content cache). The
|
||||||
|
deployment's **default project is never pruned**, whatever the registry says.
|
||||||
|
Pruning runs only on the two full-reconcile triggers — **startup** and the
|
||||||
|
**periodic reconciler sweep** — never on incidental refreshes (a collection
|
||||||
|
create, a registry webhook), so an in-app refresh can't wipe a project.
|
||||||
|
- **Safe against a transient registry read.** The prune is reached only after a
|
||||||
|
successful parse of a non-empty registry: `parse_registry` rejects an empty
|
||||||
|
project list and the registry read raises on a missing file / transport error
|
||||||
|
(the reconciler keeps last-good on that error). A flaky read can't trigger a
|
||||||
|
wipe.
|
||||||
|
|
||||||
|
**Upgrade note (no steps required for a correctly-configured deployment).** On
|
||||||
|
the first startup after upgrading, any project that exists in the database but is
|
||||||
|
**absent from your `projects.yaml`** will be pruned. For a deployment whose
|
||||||
|
database was only ever populated from its current registry this is a no-op (every
|
||||||
|
DB project is in `projects.yaml`). If you have deliberately out-of-band projects
|
||||||
|
in the database (there is no supported way to create one — every project goes
|
||||||
|
through `projects.yaml`), add them to the registry before upgrading. No database
|
||||||
|
migration and no configuration change.
|
||||||
|
|
||||||
|
## 0.54.1 — 2026-06-09
|
||||||
|
|
||||||
|
**Patch — the Gitea-OAuth sign-in no longer 500s when an account was first
|
||||||
|
created via OTC; no operator action required.**
|
||||||
|
|
||||||
|
`auth.provision_user` (the Gitea-OAuth callback path) matched an existing
|
||||||
|
`users` row only by `gitea_id`. A human who signed in first via the OTC
|
||||||
|
email path owns an email-only row (`gitea_id` NULL); when they later signed
|
||||||
|
in via Gitea OAuth with the same email, the lookup missed and the code
|
||||||
|
INSERTed a new row — colliding on the `idx_users_email` case-insensitive
|
||||||
|
unique index and raising `IntegrityError`, which 500-ed the callback. Now,
|
||||||
|
when no `gitea_id` row exists, `provision_user` reconciles by email and links
|
||||||
|
the OAuth identity (`gitea_id` / `gitea_login` / profile) onto the existing
|
||||||
|
row — the mirror of the OTC linker, which already links an OAuth-era email row
|
||||||
|
on first OTC sign-in. The §6.1 owner-zero bootstrap is applied on link
|
||||||
|
(matching the fresh-insert path); the row's `permission_state` is preserved so
|
||||||
|
linking never changes a human's admission status as a side effect.
|
||||||
|
|
||||||
|
## 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
|
## 0.52.3 — 2026-06-09
|
||||||
|
|
||||||
**Patch — unmatched routes render a 404 instead of a blank page or a
|
**Patch — unmatched routes render a 404 instead of a blank page or a
|
||||||
|
|||||||
@@ -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
|
auto-generated value (user-renamable); the exact format is an
|
||||||
implementation detail.
|
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*
|
Discuss vs. contribute is an *intent* affordance, not a *permission*
|
||||||
affordance. A user without contribute access to a branch sees the
|
affordance. A user without contribute access to a branch sees the
|
||||||
toggle disabled, with a sign-in or request-access path (see §8.7).
|
toggle disabled, with a sign-in or request-access path (see §8.7).
|
||||||
|
|||||||
+26
-6
@@ -919,14 +919,18 @@ def make_router(
|
|||||||
raise HTTPException(404, "Not found")
|
raise HTTPException(404, "Not found")
|
||||||
if row["state"] != "active" or not row["unreviewed"]:
|
if row["state"] != "active" or not row["unreviewed"]:
|
||||||
raise HTTPException(409, "Entry is not an unreviewed active entry")
|
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:
|
try:
|
||||||
await bot.mark_entry_reviewed(
|
await bot.mark_entry_reviewed(
|
||||||
viewer.as_actor(),
|
viewer.as_actor(),
|
||||||
org=config.gitea_org,
|
org=org,
|
||||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
meta_repo=meta_repo,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
reviewed_by=viewer.gitea_login,
|
reviewed_by=viewer.gitea_login,
|
||||||
reviewed_at=entry_mod.today(),
|
reviewed_at=entry_mod.today(),
|
||||||
|
file_path=md_path,
|
||||||
)
|
)
|
||||||
except GiteaError as e:
|
except GiteaError as e:
|
||||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
@@ -1031,7 +1035,19 @@ def make_router(
|
|||||||
# Read the proposed entry file from the head branch.
|
# Read the proposed entry file from the head branch.
|
||||||
slug = row["rfc_slug"]
|
slug = row["rfc_slug"]
|
||||||
head = row["head_branch"]
|
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
|
entry_payload: dict[str, Any] | None = None
|
||||||
if result:
|
if result:
|
||||||
text, _sha = result
|
text, _sha = result
|
||||||
@@ -1245,7 +1261,9 @@ def make_router(
|
|||||||
await bot.merge_idea_pr(
|
await bot.merge_idea_pr(
|
||||||
user.as_actor(),
|
user.as_actor(),
|
||||||
org=config.gitea_org,
|
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,
|
pr_number=pr_number,
|
||||||
slug=row["rfc_slug"],
|
slug=row["rfc_slug"],
|
||||||
)
|
)
|
||||||
@@ -1265,7 +1283,8 @@ def make_router(
|
|||||||
await bot.decline_idea_pr(
|
await bot.decline_idea_pr(
|
||||||
user.as_actor(),
|
user.as_actor(),
|
||||||
org=config.gitea_org,
|
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,
|
pr_number=pr_number,
|
||||||
slug=row["rfc_slug"],
|
slug=row["rfc_slug"],
|
||||||
comment=body.comment,
|
comment=body.comment,
|
||||||
@@ -1289,7 +1308,8 @@ def make_router(
|
|||||||
await bot.withdraw_idea_pr(
|
await bot.withdraw_idea_pr(
|
||||||
user.as_actor(),
|
user.as_actor(),
|
||||||
org=config.gitea_org,
|
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,
|
pr_number=pr_number,
|
||||||
slug=row["rfc_slug"],
|
slug=row["rfc_slug"],
|
||||||
)
|
)
|
||||||
|
|||||||
+72
-15
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
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 .bot import Bot
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .gitea import Gitea, GiteaError
|
from .gitea import Gitea, GiteaError
|
||||||
@@ -170,10 +170,7 @@ def make_router(
|
|||||||
# open edit branches, open meta-repo body-edit and metadata PRs.
|
# open edit branches, open meta-repo body-edit and metadata PRs.
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/api/rfcs/{slug}/main")
|
async def _main_payload(rfc, slug: str, viewer) -> dict[str, Any]:
|
||||||
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
|
|
||||||
viewer = auth.current_user(request)
|
|
||||||
rfc = _require_rfc(slug, viewer)
|
|
||||||
if rfc["state"] not in ("active", "super-draft"):
|
if rfc["state"] not in ("active", "super-draft"):
|
||||||
raise HTTPException(409, f"RFC is {rfc['state']}")
|
raise HTTPException(409, f"RFC is {rfc['state']}")
|
||||||
|
|
||||||
@@ -294,6 +291,23 @@ def make_router(
|
|||||||
"pre_graduation_history": pre_grad,
|
"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
|
# The bare `GET /api/rfcs/<slug>/branches/<branch>` is declared
|
||||||
# at the *bottom* of this router so the more-specific deeper GET
|
# at the *bottom* of this router so the more-specific deeper GET
|
||||||
# routes — `branches/{branch:path}/threads` and
|
# routes — `branches/{branch:path}/threads` and
|
||||||
@@ -489,6 +503,7 @@ def make_router(
|
|||||||
org=owner,
|
org=owner,
|
||||||
meta_repo=repo,
|
meta_repo=repo,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
file_path=path,
|
||||||
new_file_contents=new_content,
|
new_file_contents=new_content,
|
||||||
prior_sha=prior_sha,
|
prior_sha=prior_sha,
|
||||||
pr_title=pr_title,
|
pr_title=pr_title,
|
||||||
@@ -1048,10 +1063,7 @@ def make_router(
|
|||||||
# else, including slashed branch names like `foo/bar`.
|
# else, including slashed branch names like `foo/bar`.
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
|
async def _branch_view_payload(rfc, slug: str, branch: str, viewer) -> dict[str, Any]:
|
||||||
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)
|
|
||||||
if not _can_read_branch(slug, branch, viewer):
|
if not _can_read_branch(slug, branch, viewer):
|
||||||
raise HTTPException(403, "Branch is private")
|
raise HTTPException(403, "Branch is private")
|
||||||
|
|
||||||
@@ -1118,12 +1130,48 @@ def make_router(
|
|||||||
"capabilities": capabilities,
|
"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.)
|
# Permission + state helpers (closures, share `config` etc.)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _require_rfc(slug: str, viewer):
|
def _check_collection_in_project(project_id: str, collection_id: str) -> 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 = ?", (slug,)).fetchone()
|
"""§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:
|
if row is None:
|
||||||
raise HTTPException(404, "RFC not found")
|
raise HTTPException(404, "RFC not found")
|
||||||
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
# §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"])
|
auth.require_project_readable(viewer, row["project_id"])
|
||||||
return row
|
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
|
"""Used by every branch-scoped endpoint. Under the meta-only
|
||||||
topology (§1) the meta repo is the implicit target for every
|
topology (§1) the meta repo is the implicit target for every
|
||||||
entry — super-draft and active alike — so there is no per-RFC
|
entry — super-draft and active alike — so there is no per-RFC
|
||||||
repo check. The name is retained for call-site stability; a
|
repo check. The name is retained for call-site stability; a
|
||||||
withdrawn entry is still rejected."""
|
withdrawn entry is still rejected."""
|
||||||
row = _require_rfc(slug, viewer)
|
row = _require_rfc(slug, viewer, collection_id)
|
||||||
if row["state"] == "withdrawn":
|
if row["state"] == "withdrawn":
|
||||||
raise HTTPException(409, "RFC is withdrawn")
|
raise HTTPException(409, "RFC is withdrawn")
|
||||||
return row
|
return row
|
||||||
@@ -1183,14 +1231,23 @@ def make_router(
|
|||||||
return _is_meta_branch_name(branch)
|
return _is_meta_branch_name(branch)
|
||||||
|
|
||||||
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
|
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):
|
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)
|
owner, repo = rfc["repo"].split("/", 1)
|
||||||
return owner, repo
|
return owner, repo
|
||||||
|
|
||||||
def _file_path_for(rfc, branch: str = "main") -> str:
|
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):
|
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
|
return RFC_FILE_PATH
|
||||||
|
|
||||||
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
|
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
|
||||||
|
|||||||
@@ -344,12 +344,14 @@ def make_router(
|
|||||||
# Dual-read the meta-repo entry once (§22.4a sidecar-aware) — we need its
|
# 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
|
# git state for the graduation commit and the body to carry through
|
||||||
# unchanged (meta-only keeps the body in the entry, §13.3).
|
# unchanged (meta-only keeps the body in the entry, §13.3).
|
||||||
st = await metadata_mod.read_entry_from_git(
|
# §22/G-15: resolve the repo+path from the entry's COLLECTION, not the
|
||||||
gitea, config.gitea_org,
|
# deployment default, so an entry in a non-default project graduates in
|
||||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
# 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:
|
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
|
super_draft_entry = st.entry
|
||||||
|
|
||||||
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
||||||
@@ -378,7 +380,7 @@ def make_router(
|
|||||||
extra=dict(super_draft_entry.extra),
|
extra=dict(super_draft_entry.extra),
|
||||||
)
|
)
|
||||||
graduation_files = metadata_mod.write_entry_files(
|
graduation_files = metadata_mod.write_entry_files(
|
||||||
f"rfcs/{slug}.md", graduated_entry, st)
|
md_path, graduated_entry, st)
|
||||||
|
|
||||||
state = _new_active(
|
state = _new_active(
|
||||||
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
|
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
|
||||||
@@ -398,6 +400,7 @@ def make_router(
|
|||||||
coro = _orchestrate(
|
coro = _orchestrate(
|
||||||
config=config, gitea=gitea, bot=bot,
|
config=config, gitea=gitea, bot=bot,
|
||||||
actor=viewer.as_actor(), state=state,
|
actor=viewer.as_actor(), state=state,
|
||||||
|
meta_repo=meta_repo,
|
||||||
graduation_files=graduation_files,
|
graduation_files=graduation_files,
|
||||||
)
|
)
|
||||||
if request.query_params.get("_sync") == "1":
|
if request.query_params.get("_sync") == "1":
|
||||||
@@ -478,21 +481,21 @@ def make_router(
|
|||||||
if already:
|
if already:
|
||||||
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
||||||
|
|
||||||
st = await metadata_mod.read_entry_from_git(
|
# §22/G-15: resolve repo+path from the entry's collection, not the default.
|
||||||
gitea, config.gitea_org,
|
org, meta_repo, md_path = projects_mod.entry_location(
|
||||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
config, rfc["collection_id"], slug)
|
||||||
)
|
st = await metadata_mod.read_entry_from_git(gitea, org, meta_repo, md_path)
|
||||||
if st is None:
|
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:
|
if viewer.gitea_login in st.entry.owners:
|
||||||
return {"ok": True, "noop": True}
|
return {"ok": True, "noop": True}
|
||||||
ent = metadata_mod.apply_values(
|
ent = metadata_mod.apply_values(
|
||||||
st.entry, {"owners": st.entry.owners + [viewer.gitea_login]})
|
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:
|
try:
|
||||||
pr = await bot.open_claim_pr(
|
pr = await bot.open_claim_pr(
|
||||||
viewer.as_actor(),
|
viewer.as_actor(),
|
||||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
org=org, meta_repo=meta_repo,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
files=files,
|
files=files,
|
||||||
)
|
)
|
||||||
@@ -521,12 +524,15 @@ def make_router(
|
|||||||
403, "Only this RFC's owners or a site owner may retire it"
|
403, "Only this RFC's owners or a site owner may retire it"
|
||||||
)
|
)
|
||||||
prior_state = rfc["state"]
|
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"})
|
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(
|
await _run_state_flip(
|
||||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
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",
|
verb="retire", target_state="retired",
|
||||||
)
|
)
|
||||||
_audit(
|
_audit(
|
||||||
@@ -550,14 +556,17 @@ def make_router(
|
|||||||
viewer = auth.require_user(request)
|
viewer = auth.require_user(request)
|
||||||
if viewer.role != "owner":
|
if viewer.role != "owner":
|
||||||
raise HTTPException(403, "Only a site owner may un-retire an RFC")
|
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)
|
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})
|
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(
|
await _run_state_flip(
|
||||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
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,
|
verb="unretire", target_state=restored,
|
||||||
)
|
)
|
||||||
_audit(
|
_audit(
|
||||||
@@ -598,16 +607,14 @@ def make_router(
|
|||||||
raise HTTPException(409, f"RFC is {row['state']}, not retired")
|
raise HTTPException(409, f"RFC is {row['state']}, not retired")
|
||||||
return row
|
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,
|
"""Dual-read an entry from meta-main → EntryGitState (sidecar-aware,
|
||||||
§22.4a). A migrated body-only `.md` reads cleanly; never raises on bad
|
§22.4a). A migrated body-only `.md` reads cleanly; never raises on bad
|
||||||
metadata (INV-3)."""
|
metadata (INV-3). `org`/`repo`/`md_path` are collection-resolved by the
|
||||||
st = await metadata_mod.read_entry_from_git(
|
caller (§22/G-15) so a non-default project's entry reads its own repo."""
|
||||||
gitea, config.gitea_org,
|
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
|
||||||
)
|
|
||||||
if st is None:
|
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
|
return st
|
||||||
|
|
||||||
async def _refresh_catalog() -> None:
|
async def _refresh_catalog() -> None:
|
||||||
@@ -636,6 +643,7 @@ async def _orchestrate(
|
|||||||
bot: Bot,
|
bot: Bot,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
state: GraduationState,
|
state: GraduationState,
|
||||||
|
meta_repo: str,
|
||||||
graduation_files: list[dict],
|
graduation_files: list[dict],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Open the flip PR, then merge it. Two steps, no transaction:
|
"""Open the flip PR, then merge it. Two steps, no transaction:
|
||||||
@@ -654,7 +662,7 @@ async def _orchestrate(
|
|||||||
try:
|
try:
|
||||||
pr = await bot.open_graduation_pr(
|
pr = await bot.open_graduation_pr(
|
||||||
actor,
|
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,
|
slug=state.slug,
|
||||||
files=graduation_files,
|
files=graduation_files,
|
||||||
rfc_id=state.rfc_id,
|
rfc_id=state.rfc_id,
|
||||||
@@ -673,14 +681,15 @@ async def _orchestrate(
|
|||||||
try:
|
try:
|
||||||
await bot.merge_graduation_pr(
|
await bot.merge_graduation_pr(
|
||||||
actor,
|
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,
|
pr_number=state.new_pr_number,
|
||||||
head_branch=state.graduation_branch or "",
|
head_branch=state.graduation_branch or "",
|
||||||
slug=state.slug, rfc_id=state.rfc_id,
|
slug=state.slug, rfc_id=state.rfc_id,
|
||||||
)
|
)
|
||||||
except GiteaError as e:
|
except GiteaError as e:
|
||||||
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
|
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)
|
await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
|
||||||
return
|
return
|
||||||
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
|
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
|
||||||
@@ -723,7 +732,7 @@ async def _orchestrate(
|
|||||||
|
|
||||||
|
|
||||||
async def _cleanup_unmerged(
|
async def _cleanup_unmerged(
|
||||||
*, config: Config, bot: Bot, actor: Actor, state: GraduationState,
|
*, config: Config, bot: Bot, actor: Actor, state: GraduationState, meta_repo: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
|
"""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
|
branch. Close the PR and delete the branch so failed attempts don't
|
||||||
@@ -735,7 +744,7 @@ async def _cleanup_unmerged(
|
|||||||
try:
|
try:
|
||||||
await bot.close_graduation_pr(
|
await bot.close_graduation_pr(
|
||||||
actor,
|
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,
|
pr_number=state.new_pr_number,
|
||||||
head_branch=state.graduation_branch or "",
|
head_branch=state.graduation_branch or "",
|
||||||
slug=state.slug, reason="graduation merge failed",
|
slug=state.slug, reason="graduation merge failed",
|
||||||
@@ -748,7 +757,7 @@ async def _cleanup_unmerged(
|
|||||||
await bot.delete_branch(
|
await bot.delete_branch(
|
||||||
actor,
|
actor,
|
||||||
owner=config.gitea_org,
|
owner=config.gitea_org,
|
||||||
repo=(projects_mod.default_content_repo(config) or ""),
|
repo=meta_repo,
|
||||||
branch=branch_name,
|
branch=branch_name,
|
||||||
slug=state.slug,
|
slug=state.slug,
|
||||||
action_kind="delete_post_merge_branch",
|
action_kind="delete_post_merge_branch",
|
||||||
@@ -843,6 +852,7 @@ async def _run_state_flip(
|
|||||||
gitea: Gitea,
|
gitea: Gitea,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
|
meta_repo: str,
|
||||||
slug: str,
|
slug: str,
|
||||||
files: list[dict],
|
files: list[dict],
|
||||||
verb: str,
|
verb: str,
|
||||||
@@ -858,7 +868,7 @@ async def _run_state_flip(
|
|||||||
try:
|
try:
|
||||||
pr = await bot.open_retire_flip_pr(
|
pr = await bot.open_retire_flip_pr(
|
||||||
actor,
|
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,
|
slug=slug, files=files,
|
||||||
verb=verb, target_state=target_state,
|
verb=verb, target_state=target_state,
|
||||||
)
|
)
|
||||||
@@ -869,7 +879,7 @@ async def _run_state_flip(
|
|||||||
try:
|
try:
|
||||||
await bot.merge_retire_flip_pr(
|
await bot.merge_retire_flip_pr(
|
||||||
actor,
|
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,
|
pr_number=pr_number, head_branch=head_branch,
|
||||||
slug=slug, verb=verb,
|
slug=slug, verb=verb,
|
||||||
)
|
)
|
||||||
@@ -878,12 +888,12 @@ async def _run_state_flip(
|
|||||||
# accumulate (mirrors graduation's `_cleanup_unmerged`).
|
# accumulate (mirrors graduation's `_cleanup_unmerged`).
|
||||||
try:
|
try:
|
||||||
await bot.close_graduation_pr(
|
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,
|
pr_number=pr_number, head_branch=head_branch,
|
||||||
slug=slug, reason=f"{verb} merge failed",
|
slug=slug, reason=f"{verb} merge failed",
|
||||||
)
|
)
|
||||||
await bot.delete_branch(
|
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,
|
branch=head_branch, slug=slug,
|
||||||
action_kind="delete_post_merge_branch",
|
action_kind="delete_post_merge_branch",
|
||||||
reason=f"{verb} merge failed",
|
reason=f"{verb} merge failed",
|
||||||
|
|||||||
@@ -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:
|
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
def _content_repo() -> tuple[str, str]:
|
def _content_repo(collection_id: str) -> tuple[str, str]:
|
||||||
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
# §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:
|
def _md_path(collection_id: str, slug: str) -> str:
|
||||||
sub = collections_mod.subfolder_of(collection_id) or ""
|
sub = collections_mod.subfolder_of(collection_id) or ""
|
||||||
@@ -83,7 +88,7 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
|||||||
if unknown:
|
if unknown:
|
||||||
raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(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)
|
md_path = _md_path(collection_id, slug)
|
||||||
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||||
if st is None:
|
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":
|
if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
|
||||||
raise HTTPException(422, f"op {body.op} requires a tags field")
|
raise HTTPException(422, f"op {body.op} requires a tags field")
|
||||||
|
|
||||||
org, repo = _content_repo()
|
org, repo = _content_repo(collection_id)
|
||||||
applied: list[str] = []
|
applied: list[str] = []
|
||||||
rejected: list[dict[str, str]] = []
|
rejected: list[dict[str, str]] = []
|
||||||
all_ops: list[dict[str, Any]] = []
|
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")
|
raise HTTPException(404, "Collection not in project")
|
||||||
if not auth.is_collection_superuser(viewer, collection_id):
|
if not auth.is_collection_superuser(viewer, collection_id):
|
||||||
raise HTTPException(403, "Owner access required to migrate a collection")
|
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 ""
|
subfolder = collections_mod.subfolder_of(collection_id) or ""
|
||||||
try:
|
try:
|
||||||
result = await metadata_mod.migrate_collection(
|
result = await metadata_mod.migrate_collection(
|
||||||
|
|||||||
@@ -690,14 +690,20 @@ def make_router(
|
|||||||
return not rfc["repo"]
|
return not rfc["repo"]
|
||||||
|
|
||||||
def _owner_repo(rfc) -> tuple[str, str]:
|
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):
|
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)
|
owner, repo = rfc["repo"].split("/", 1)
|
||||||
return owner, repo
|
return owner, repo
|
||||||
|
|
||||||
def _file_path_for(rfc) -> str:
|
def _file_path_for(rfc) -> str:
|
||||||
|
# §22/G-15: the collection's `<subfolder>/rfcs/<slug>.md`.
|
||||||
if _is_meta_resident(rfc):
|
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
|
return RFC_FILE_PATH
|
||||||
|
|
||||||
def _extract_body(rfc, file_contents: str) -> str:
|
def _extract_body(rfc, file_contents: str) -> str:
|
||||||
|
|||||||
+27
-3
@@ -145,6 +145,19 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
|||||||
|
|
||||||
c = db.conn()
|
c = db.conn()
|
||||||
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
|
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
|
||||||
|
# No row for this gitea_id yet. A prior OTC sign-in (`provision_or_link_user`)
|
||||||
|
# may have created an email-only row with this same email and `gitea_id`
|
||||||
|
# NULL; `idx_users_email` is a unique index, so a blind INSERT below would
|
||||||
|
# raise IntegrityError and 500 the OAuth callback. Reconcile by email: link
|
||||||
|
# the OAuth identity onto that existing row instead. Mirror of the OTC
|
||||||
|
# linker, which links an OAuth-era email row on first OTC sign-in.
|
||||||
|
linked = False
|
||||||
|
if existing is None and email:
|
||||||
|
existing = c.execute(
|
||||||
|
"SELECT * FROM users WHERE email = ? COLLATE NOCASE AND gitea_id IS NULL LIMIT 1",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
linked = existing is not None
|
||||||
if existing is None:
|
if existing is None:
|
||||||
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
|
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
|
||||||
# v0.8.0: a fresh OAuth-provisioned user is also subject to
|
# v0.8.0: a fresh OAuth-provisioned user is also subject to
|
||||||
@@ -166,15 +179,26 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
|||||||
permission_state = "granted"
|
permission_state = "granted"
|
||||||
else:
|
else:
|
||||||
user_id = existing["id"]
|
user_id = existing["id"]
|
||||||
role = existing["role"]
|
# On a fresh link this OAuth sign-in is the row's first, so apply the
|
||||||
|
# §6.1 owner-zero bootstrap (matching the INSERT path). For a row already
|
||||||
|
# matched by gitea_id the role is settled — preserve it. `permission_state`
|
||||||
|
# is preserved either way: linking an OAuth identity onto an existing
|
||||||
|
# email row must not silently change the human's admission status.
|
||||||
|
if linked and config.owner_gitea_login and login == config.owner_gitea_login:
|
||||||
|
role = "owner"
|
||||||
|
else:
|
||||||
|
role = existing["role"]
|
||||||
permission_state = existing["permission_state"] or "granted"
|
permission_state = existing["permission_state"] or "granted"
|
||||||
|
# Setting `gitea_id` matters only on the link path (it was NULL); on the
|
||||||
|
# gitea_id-matched path it re-writes the same value. `role` is likewise a
|
||||||
|
# no-op there. Always refresh the mutable profile fields + last_seen.
|
||||||
c.execute(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, last_seen_at = datetime('now')
|
SET gitea_id = ?, gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, role = ?, last_seen_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(login, email, display, avatar, user_id),
|
(gitea_id, login, email, display, avatar, role, user_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
return SessionUser(
|
return SessionUser(
|
||||||
|
|||||||
+15
-8
@@ -450,17 +450,19 @@ class Bot:
|
|||||||
org: str,
|
org: str,
|
||||||
meta_repo: str,
|
meta_repo: str,
|
||||||
slug: str,
|
slug: str,
|
||||||
|
file_path: str,
|
||||||
new_file_contents: str,
|
new_file_contents: str,
|
||||||
prior_sha: str,
|
prior_sha: str,
|
||||||
pr_title: str,
|
pr_title: str,
|
||||||
pr_description: str,
|
pr_description: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Per §9.5: a metadata-pane edit (title or tags) on a super-draft
|
"""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
|
opens a tiny meta-repo PR that touches only the frontmatter of the
|
||||||
`rfcs/<slug>.md`. One commit, one PR, easy to triage. The branch
|
entry's `.md`. `file_path` is the collection-resolved path (§22/G-15:
|
||||||
name uses the dash-separated `metadata-<slug>-<6hex>` shape — same
|
`<subfolder>/rfcs/<slug>.md`), not assumed to be at the repo root. One
|
||||||
routing-friendly form Slice 4 picked for edit branches per the
|
commit, one PR, easy to triage. The branch name uses the dash-separated
|
||||||
§19.2 path-routing candidate.
|
`metadata-<slug>-<6hex>` shape — same routing-friendly form Slice 4
|
||||||
|
picked for edit branches per the §19.2 path-routing candidate.
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
@@ -471,7 +473,7 @@ class Bot:
|
|||||||
result = await self._gitea.update_file(
|
result = await self._gitea.update_file(
|
||||||
org,
|
org,
|
||||||
meta_repo,
|
meta_repo,
|
||||||
f"rfcs/{slug}.md",
|
file_path,
|
||||||
content=new_file_contents,
|
content=new_file_contents,
|
||||||
sha=prior_sha,
|
sha=prior_sha,
|
||||||
message=commit_message,
|
message=commit_message,
|
||||||
@@ -1210,13 +1212,18 @@ class Bot:
|
|||||||
slug: str,
|
slug: str,
|
||||||
reviewed_by: str,
|
reviewed_by: str,
|
||||||
reviewed_at: str,
|
reviewed_at: str,
|
||||||
|
file_path: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Clear §22.4c unreviewed on an active entry by writing its metadata
|
"""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
|
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
|
`.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
|
same commit. Stamps the §6.5 On-behalf-of trailer and writes an
|
||||||
actions-log row, mirroring the graduation stamp's bot-write shape."""
|
actions-log row, mirroring the graduation stamp's bot-write shape.
|
||||||
path = f"rfcs/{slug}.md"
|
|
||||||
|
`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)
|
st = await metadata_mod.read_entry_from_git(self._gitea, org, meta_repo, path)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise GiteaError(404, f"{path} not found")
|
raise GiteaError(404, f"{path} not found")
|
||||||
|
|||||||
+66
-54
@@ -426,73 +426,85 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
|||||||
of slashes per the §19.2 path-routing candidate.
|
of slashes per the §19.2 path-routing candidate.
|
||||||
"""
|
"""
|
||||||
org = config.gitea_org
|
org = config.gitea_org
|
||||||
repo = projects_mod.default_content_repo(config)
|
# §22/G-15: scan EVERY project's content_repo (not just the default), so an
|
||||||
if not repo:
|
# entry in a non-default project gets its edit branches + synthesized `main`
|
||||||
log.warning("refresh_meta_branches: default project has no content_repo yet; skipping")
|
# row cached and its branch dropdown / has-commits-ahead check work. Mirrors
|
||||||
return
|
# refresh_meta_pulls' per-project loop.
|
||||||
try:
|
prows = db.conn().execute(
|
||||||
branches = await gitea.list_branches(org, repo)
|
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
|
||||||
except GiteaError as e:
|
).fetchall()
|
||||||
log.warning("refresh_meta_branches: %s", e)
|
if not prows:
|
||||||
|
log.warning("refresh_meta_branches: no projects with a content_repo yet; skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
meta_main_sha = ""
|
|
||||||
meta_main_ts = None
|
|
||||||
edit_keys_seen: set[tuple[str, str]] = set()
|
edit_keys_seen: set[tuple[str, str]] = set()
|
||||||
for b in branches:
|
for prow in prows:
|
||||||
name = b.get("name") or ""
|
repo = prow["content_repo"]
|
||||||
head_sha = (b.get("commit") or {}).get("id") or ""
|
try:
|
||||||
last_commit_at = (b.get("commit") or {}).get("timestamp")
|
branches = await gitea.list_branches(org, repo)
|
||||||
if name == "main":
|
except GiteaError as e:
|
||||||
meta_main_sha = head_sha
|
log.warning("refresh_meta_branches: %s (%s)", e, repo)
|
||||||
meta_main_ts = last_commit_at
|
|
||||||
continue
|
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
|
meta_main_sha = ""
|
||||||
# §10.1 has-commits-ahead check in api_prs.py works uniformly. The
|
meta_main_ts = None
|
||||||
# head_sha is the meta-repo main's tip — every super-draft edit branch
|
for b in branches:
|
||||||
# diverges from this single point.
|
name = b.get("name") or ""
|
||||||
if meta_main_sha:
|
head_sha = (b.get("commit") or {}).get("id") or ""
|
||||||
super_drafts = db.conn().execute(
|
last_commit_at = (b.get("commit") or {}).get("timestamp")
|
||||||
"SELECT slug FROM cached_rfcs "
|
if name == "main":
|
||||||
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
|
meta_main_sha = head_sha
|
||||||
).fetchall()
|
meta_main_ts = last_commit_at
|
||||||
for r in super_drafts:
|
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(
|
db.conn().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
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
|
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||||
head_sha = excluded.head_sha,
|
head_sha = excluded.head_sha,
|
||||||
|
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
|
||||||
last_commit_at = excluded.last_commit_at
|
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
|
# Mark previously-known edit branches that disappeared as deleted per
|
||||||
# §11.5 / §12. Keep the row so chat history survives the branch's
|
# §11.5 / §12. Keep the row so chat history survives the branch's
|
||||||
# deletion in Gitea.
|
# deletion in Gitea.
|
||||||
@@ -786,7 +798,7 @@ class Reconciler:
|
|||||||
log.info("reconciler: starting sweep")
|
log.info("reconciler: starting sweep")
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
await registry_mod.refresh_registry(self._config, self._gitea)
|
await registry_mod.refresh_registry(self._config, self._gitea, prune=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("reconciler: registry refresh failed; keeping last-good projects")
|
log.exception("reconciler: registry refresh failed; keeping last-good projects")
|
||||||
await refresh_meta_repo(self._config, self._gitea)
|
await refresh_meta_repo(self._config, self._gitea)
|
||||||
|
|||||||
@@ -293,20 +293,21 @@ async def _delete_branch_via_bot(
|
|||||||
(we leave the branch row in place — a subsequent reconciler sweep
|
(we leave the branch row in place — a subsequent reconciler sweep
|
||||||
will reconcile or the operator can intervene)."""
|
will reconcile or the operator can intervene)."""
|
||||||
rfc = db.conn().execute(
|
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()
|
).fetchone()
|
||||||
if rfc is None:
|
if rfc is None:
|
||||||
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
|
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
|
||||||
return False
|
return False
|
||||||
if not rfc["repo"]:
|
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:
|
if not repo:
|
||||||
log.warning(
|
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,
|
slug, branch,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
owner = config.gitea_org
|
|
||||||
elif "/" in rfc["repo"]:
|
elif "/" in rfc["repo"]:
|
||||||
owner, repo = rfc["repo"].split("/", 1)
|
owner, repo = rfc["repo"].split("/", 1)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ async def lifespan(app: FastAPI):
|
|||||||
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
||||||
# fresh thereafter and tolerates a later bad PR.
|
# fresh thereafter and tolerates a later bad PR.
|
||||||
try:
|
try:
|
||||||
await registry_mod.refresh_registry(config, gitea)
|
await registry_mod.refresh_registry(config, gitea, prune=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
||||||
|
|||||||
@@ -181,6 +181,98 @@ def reconcile_default_collection_id(config: Config) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_absent_projects(config: Config, present_ids: set[str]) -> list[str]:
|
||||||
|
"""Delete projects the registry no longer declares, plus their collections
|
||||||
|
and every project-scoped row. Whole-project granularity only.
|
||||||
|
|
||||||
|
`present_ids` is the authoritative set of project ids from the just-parsed
|
||||||
|
registry. Safety is structural: the only caller (`registry.refresh_registry`)
|
||||||
|
raises on a read/transport error, and `parse_registry` rejects an empty
|
||||||
|
project list — so this is reached ONLY after a real, non-empty registry has
|
||||||
|
been parsed. A transient registry-read failure therefore can't trigger a wipe.
|
||||||
|
The default project is never pruned regardless of what the registry says.
|
||||||
|
|
||||||
|
Whole-project, not per-collection/entry: a project still present keeps all its
|
||||||
|
rows here. Pruning individual entries within a present project belongs to the
|
||||||
|
best-effort content cache (whose list can transiently fail), not to this.
|
||||||
|
|
||||||
|
Mirrors `restamp_default_project`'s FK-off + `foreign_key_check` pattern. After
|
||||||
|
029 the entry-corpus tables key on `collection_id` (not `project_id`), so the
|
||||||
|
delete spans three discovered sets — project_id-keyed tables, collection_id-
|
||||||
|
keyed tables (keyed to the stale projects' collections), and the lone non-keyed
|
||||||
|
descendant `thread_messages` (FK→threads) — in one transaction with FK
|
||||||
|
enforcement off and a `foreign_key_check` backstop that rolls back on any
|
||||||
|
dangling reference (so an incomplete delete fails loudly rather than corrupts).
|
||||||
|
Returns the pruned project ids (empty when nothing is stale).
|
||||||
|
"""
|
||||||
|
keep = set(present_ids)
|
||||||
|
keep.add(resolved_default_id(config))
|
||||||
|
keep.add(DEFAULT_PROJECT_ID)
|
||||||
|
conn = db.conn()
|
||||||
|
stale = [
|
||||||
|
r["id"] for r in conn.execute("SELECT id FROM projects").fetchall()
|
||||||
|
if r["id"] not in keep
|
||||||
|
]
|
||||||
|
if not stale:
|
||||||
|
return []
|
||||||
|
|
||||||
|
stale_collections = [
|
||||||
|
r["id"] for r in conn.execute(
|
||||||
|
f"SELECT id FROM collections WHERE project_id IN ({','.join('?' * len(stale))})",
|
||||||
|
stale,
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||||
|
|
||||||
|
def _has(table: str, col: str) -> bool:
|
||||||
|
return any(c["name"] == col for c in conn.execute(f"PRAGMA table_info({table})"))
|
||||||
|
|
||||||
|
pid_tables = [t for t in tables if t != "projects" and _has(t, "project_id")]
|
||||||
|
cid_tables = [t for t in tables if _has(t, "collection_id")]
|
||||||
|
p_ph = ",".join("?" * len(stale))
|
||||||
|
c_ph = ",".join("?" * len(stale_collections)) if stale_collections else ""
|
||||||
|
|
||||||
|
conn.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
# Non-keyed descendant: thread_messages hangs off threads(id), which is
|
||||||
|
# project_id-keyed below. Clear it first by thread lineage.
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM thread_messages WHERE thread_id IN "
|
||||||
|
f"(SELECT id FROM threads WHERE project_id IN ({p_ph}))",
|
||||||
|
stale,
|
||||||
|
)
|
||||||
|
if stale_collections:
|
||||||
|
for t in cid_tables:
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM {t} WHERE collection_id IN ({c_ph})", stale_collections
|
||||||
|
)
|
||||||
|
for t in pid_tables: # includes collections + cached_prs + threads/changes/…
|
||||||
|
conn.execute(f"DELETE FROM {t} WHERE project_id IN ({p_ph})", stale)
|
||||||
|
conn.execute(f"DELETE FROM projects WHERE id IN ({p_ph})", stale)
|
||||||
|
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||||
|
if violations:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"prune left foreign-key violations: {[tuple(v) for v in violations]}"
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
log.warning(
|
||||||
|
"prune: removed %d project(s) absent from the registry: %s "
|
||||||
|
"(%d collection(s), across %d project- + %d collection-keyed tables)",
|
||||||
|
len(stale), stale, len(stale_collections), len(pid_tables), len(cid_tables),
|
||||||
|
)
|
||||||
|
return stale
|
||||||
|
|
||||||
|
|
||||||
def default_content_repo(config: Config) -> str | None:
|
def default_content_repo(config: Config) -> str | None:
|
||||||
"""The content repo the single-corpus mirror reads, from the default
|
"""The content repo the single-corpus mirror reads, from the default
|
||||||
project's row (filled by the registry mirror). Replaces the retired
|
project's row (filled by the registry mirror). Replaces the retired
|
||||||
@@ -201,6 +293,37 @@ def content_repo(project_id: str) -> str | None:
|
|||||||
return row["content_repo"] if row and row["content_repo"] else 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:
|
def project_initial_state(project_id: str) -> str:
|
||||||
"""§22.4b landing state for new entries in a project's default collection
|
"""§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).
|
(the per-corpus field moved down to the collection in migration 029).
|
||||||
|
|||||||
+23
-2
@@ -333,11 +333,18 @@ async def _mirror_named_collections(config: Config, gitea: Gitea, doc: RegistryD
|
|||||||
_upsert_named_collection(proj, subdir, ce, sha)
|
_upsert_named_collection(proj, subdir, ce, sha)
|
||||||
|
|
||||||
|
|
||||||
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
async def refresh_registry(config: Config, gitea: Gitea, *, prune: bool = False) -> None:
|
||||||
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
||||||
|
|
||||||
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
|
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
|
||||||
on transport failure; the caller chooses fatal-vs-tolerated.
|
on transport failure; the caller chooses fatal-vs-tolerated.
|
||||||
|
|
||||||
|
`prune=True` additionally removes projects the registry no longer declares
|
||||||
|
(and their collections + entries) — see `projects.prune_absent_projects`.
|
||||||
|
It is OFF by default and enabled only on the full-reconcile triggers
|
||||||
|
(startup + the periodic sweep), never on incidental refreshes (a collection
|
||||||
|
create, a webhook) that don't change the project set — keeping the
|
||||||
|
destructive sweep on the few code paths that mean "reconcile to the registry."
|
||||||
"""
|
"""
|
||||||
item = await gitea.get_contents(
|
item = await gitea.get_contents(
|
||||||
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
||||||
@@ -355,4 +362,18 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
|||||||
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
|
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
|
||||||
# §22 S2: discover + upsert named collections from each content repo.
|
# §22 S2: discover + upsert named collections from each content repo.
|
||||||
await _mirror_named_collections(config, gitea, doc, sha)
|
await _mirror_named_collections(config, gitea, doc, sha)
|
||||||
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|
# Prune projects the registry no longer declares (additive-only was a §9
|
||||||
|
# fragility — a re-pin to a new registry stranded the old projects' entries,
|
||||||
|
# starving writes). Safe here: we only reach this line after a successful
|
||||||
|
# parse of a non-empty registry (parse_registry rejects an empty project list
|
||||||
|
# and the read above raises on transport failure), so a transient failure
|
||||||
|
# never prunes. Whole-project granularity; the default project is never pruned.
|
||||||
|
pruned = (
|
||||||
|
projects_mod.prune_absent_projects(config, {e.id for e in doc.projects})
|
||||||
|
if prune else []
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"registry: mirrored %d project(s) at %s%s",
|
||||||
|
len(doc.projects), sha,
|
||||||
|
f"; pruned {len(pruned)} absent: {pruned}" if pruned else "",
|
||||||
|
)
|
||||||
|
|||||||
+12
-5
@@ -80,10 +80,17 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
|||||||
payload = {}
|
payload = {}
|
||||||
repo_full = (payload.get("repository") or {}).get("full_name") or ""
|
repo_full = (payload.get("repository") or {}).get("full_name") or ""
|
||||||
registry_full = f"{config.gitea_org}/{config.registry_repo}"
|
registry_full = f"{config.gitea_org}/{config.registry_repo}"
|
||||||
content_repo = projects_mod.default_content_repo(config)
|
# §22/G-15: a corpus push can land on ANY project's content_repo, not
|
||||||
if not content_repo:
|
# just the default — recognise the full set so a non-default project's
|
||||||
log.warning("webhook: default project content_repo is unknown; corpus refresh skipped")
|
# push triggers the (multi-project) corpus/branch/PR refresh.
|
||||||
content_full = f"{config.gitea_org}/{content_repo}" if content_repo else None
|
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:
|
try:
|
||||||
if repo_full == registry_full:
|
if repo_full == registry_full:
|
||||||
# §22.2: a registry-repo push re-mirrors the projects table.
|
# §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)
|
await registry_mod.refresh_registry(config, gitea)
|
||||||
except registry_mod.RegistryError:
|
except registry_mod.RegistryError:
|
||||||
log.exception("registry webhook: invalid projects.yaml; keeping last-good")
|
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_repo(config, gitea)
|
||||||
await cache.refresh_meta_branches(config, gitea)
|
await cache.refresh_meta_branches(config, gitea)
|
||||||
await cache.refresh_meta_pulls(config, gitea)
|
await cache.refresh_meta_pulls(config, gitea)
|
||||||
|
|||||||
@@ -9,3 +9,11 @@ google-generativeai>=0.8
|
|||||||
openai>=1.50
|
openai>=1.50
|
||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
bcrypt>=4.2
|
bcrypt>=4.2
|
||||||
|
|
||||||
|
# Transitive-dependency security floors (Patchwatch #37/#38/#39/#40).
|
||||||
|
# tqdm and idna arrive transitively; these floor-pins force resolution to
|
||||||
|
# the patched versions so the deployed environment clears the advisories.
|
||||||
|
# tqdm>=4.66.3 covers GHSA-r7q7-xcjw-qx8q, GHSA-g7vv-2v7x-gj9p, PYSEC-2017-74.
|
||||||
|
# idna>=3.15 covers GHSA-65pc-fj4g-8rjx (CVE-2024-3651 bypass).
|
||||||
|
tqdm>=4.66.3
|
||||||
|
idna>=3.15
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""§6 hardening — the Gitea-OAuth `auth.provision_user` reconciles by email.
|
||||||
|
|
||||||
|
Regression cover for a §9-surfaced fragility: a human who signed in first via
|
||||||
|
OTC owns an email-only `users` row (`gitea_id` NULL). When they later sign in
|
||||||
|
via Gitea OAuth with the SAME email, `provision_user` used to match only by
|
||||||
|
`gitea_id`, miss the OTC row, and INSERT a new row — colliding on the
|
||||||
|
`idx_users_email` unique index and 500-ing the callback. The fix links the OAuth
|
||||||
|
identity onto the existing email row (the mirror of the OTC linker).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
from app.config import load_config
|
||||||
|
return load_config()
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_links_onto_existing_otc_email_row(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import auth, db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
# An OTC-first user: email-only row, gitea_id NULL, still 'pending'.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||||
|
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
|
||||||
|
""",
|
||||||
|
("dual@example.com", "dual"),
|
||||||
|
)
|
||||||
|
otc_id = db.conn().execute(
|
||||||
|
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", ("dual@example.com",)
|
||||||
|
).fetchone()["id"]
|
||||||
|
|
||||||
|
# Now the same human signs in via Gitea OAuth (new gitea_id, same email).
|
||||||
|
# Case-different email proves the NOCASE match.
|
||||||
|
user = auth.provision_user(
|
||||||
|
_cfg(),
|
||||||
|
{"id": 9001, "login": "dualgitea", "email": "Dual@Example.com",
|
||||||
|
"full_name": "Dual User", "avatar_url": "http://x/a.png"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Linked onto the SAME row — no second row, no 500.
|
||||||
|
assert user.user_id == otc_id
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"SELECT id, gitea_id, gitea_login, permission_state, role FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
("dual@example.com",),
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["id"] == otc_id
|
||||||
|
assert rows[0]["gitea_id"] == 9001 # OAuth identity attached
|
||||||
|
assert rows[0]["gitea_login"] == "dualgitea"
|
||||||
|
assert rows[0]["permission_state"] == "pending" # admission state preserved
|
||||||
|
assert rows[0]["role"] == "contributor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import auth, db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
user = auth.provision_user(
|
||||||
|
_cfg(),
|
||||||
|
{"id": 9100, "login": "freshoauth", "email": "fresh@example.com",
|
||||||
|
"full_name": "Fresh", "avatar_url": ""},
|
||||||
|
)
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT gitea_id, gitea_login, role, permission_state FROM users WHERE id = ?",
|
||||||
|
(user.user_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row["gitea_id"] == 9100
|
||||||
|
assert row["gitea_login"] == "freshoauth"
|
||||||
|
# Reaching provision_user means admission passed → granted (unchanged).
|
||||||
|
assert row["permission_state"] == "granted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_returning_oauth_user_matched_by_gitea_id_not_duplicated(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import auth, db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
provision_user_row(user_id=55, login="returning", role="contributor")
|
||||||
|
before = db.conn().execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"]
|
||||||
|
|
||||||
|
user = auth.provision_user(
|
||||||
|
_cfg(),
|
||||||
|
{"id": 55, "login": "returning-renamed", "email": "returning@test",
|
||||||
|
"full_name": "Returning", "avatar_url": ""},
|
||||||
|
)
|
||||||
|
after = db.conn().execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"]
|
||||||
|
|
||||||
|
assert user.user_id == 55
|
||||||
|
assert after == before # matched by gitea_id; no new row
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT gitea_login FROM users WHERE id = ?", (55,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["gitea_login"] == "returning-renamed" # profile refreshed
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""§9 hardening — the registry reconcile prunes projects the registry no longer
|
||||||
|
declares (was additive-only).
|
||||||
|
|
||||||
|
A re-pin to a new registry used to strand the old projects' rows (PPE's
|
||||||
|
stale-ecomm 1238-row entry cache starving writes). `prune_absent_projects` now
|
||||||
|
deletes a removed project together with its collections and every project-scoped
|
||||||
|
row, at whole-project granularity, with a `foreign_key_check` backstop. The
|
||||||
|
safety guard is structural: `refresh_registry` raises on a read/transport error
|
||||||
|
and `parse_registry` rejects an empty project list, so the prune is reached only
|
||||||
|
after a real, non-empty registry parse — a transient failure can't wipe data.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.db as db
|
||||||
|
from app import projects, registry
|
||||||
|
|
||||||
|
|
||||||
|
class _Cfg:
|
||||||
|
def __init__(self, path, default_id):
|
||||||
|
self.database_path = path
|
||||||
|
self.default_project_id = default_id
|
||||||
|
|
||||||
|
|
||||||
|
def _two_project_db(monkeypatch, default_id="ohm"):
|
||||||
|
"""A multi-project DB: default project 'ohm' (collection 'default') + a second
|
||||||
|
project 'ecomm' (collection 'ecomm'), each with a rich set of project- and
|
||||||
|
collection-keyed rows, plus the non-keyed `thread_messages` descendant."""
|
||||||
|
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||||
|
cfg = _Cfg(path, default_id)
|
||||||
|
db.run_migrations(cfg)
|
||||||
|
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||||
|
conn = db.conn()
|
||||||
|
conn.execute("DELETE FROM collections")
|
||||||
|
conn.execute("DELETE FROM projects")
|
||||||
|
conn.execute("INSERT INTO projects (id,name,content_repo,visibility) VALUES ('ohm','OHM','ohm-content','public')")
|
||||||
|
conn.execute("INSERT INTO projects (id,name,content_repo,visibility) VALUES ('ecomm','Ecomm','ecomm-content','public')")
|
||||||
|
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
||||||
|
"VALUES ('default','ohm','document','','super-draft','public','OHM')")
|
||||||
|
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
||||||
|
"VALUES ('ecomm','ecomm','bdd','ecomm','super-draft','public','Ecomm')")
|
||||||
|
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
|
||||||
|
|
||||||
|
def seed(proj, coll, slug):
|
||||||
|
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES (?,?,'active',?)",
|
||||||
|
(slug, slug.title(), coll))
|
||||||
|
conn.execute("INSERT INTO cached_branches (rfc_slug,branch_name,collection_id) VALUES (?, 'main', ?)",
|
||||||
|
(slug, coll))
|
||||||
|
conn.execute("INSERT INTO cached_prs (rfc_slug,pr_kind,repo,pr_number,title,state,project_id) "
|
||||||
|
"VALUES (?, 'rfc_branch', ?, 1, 't', 'open', ?)", (slug, proj, proj))
|
||||||
|
conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,?,?)", (slug, coll))
|
||||||
|
conn.execute("INSERT INTO watches (user_id,rfc_slug,state,set_by,collection_id) VALUES (1,?,'watching','explicit',?)",
|
||||||
|
(slug, coll))
|
||||||
|
conn.execute("INSERT INTO changes (rfc_slug,branch_name,kind,original,proposed,project_id) "
|
||||||
|
"VALUES (?, 'main', 'ai', 'o', 'p', ?)", (slug, proj))
|
||||||
|
conn.execute("INSERT INTO notifications (recipient_user_id,event_kind,project_id) VALUES (1,'x',?)", (proj,))
|
||||||
|
cur = conn.execute("INSERT INTO threads (rfc_slug,anchor_kind,thread_kind,project_id) "
|
||||||
|
"VALUES (?, 'whole-doc', 'chat', ?)", (slug, proj))
|
||||||
|
conn.execute("INSERT INTO thread_messages (thread_id,role,text) VALUES (?, 'user', 'hi')", (cur.lastrowid,))
|
||||||
|
|
||||||
|
seed("ohm", "default", "human")
|
||||||
|
seed("ecomm", "ecomm", "cart")
|
||||||
|
assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] # seed is FK-clean
|
||||||
|
return cfg, conn
|
||||||
|
|
||||||
|
|
||||||
|
_PRUNED_TABLES = [
|
||||||
|
"cached_rfcs", "cached_branches", "cached_prs", "stars", "watches",
|
||||||
|
"changes", "notifications", "threads",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_removes_absent_project_and_all_its_data(monkeypatch):
|
||||||
|
cfg, conn = _two_project_db(monkeypatch, default_id="ohm")
|
||||||
|
pruned = projects.prune_absent_projects(cfg, {"ohm"}) # ecomm absent
|
||||||
|
assert pruned == ["ecomm"]
|
||||||
|
|
||||||
|
# ecomm: project, collection, and every keyed row gone.
|
||||||
|
assert conn.execute("SELECT 1 FROM projects WHERE id='ecomm'").fetchone() is None
|
||||||
|
assert conn.execute("SELECT 1 FROM collections WHERE id='ecomm'").fetchone() is None
|
||||||
|
for t in _PRUNED_TABLES:
|
||||||
|
col = "collection_id" if t in ("cached_rfcs", "cached_branches", "stars", "watches") else None
|
||||||
|
if col:
|
||||||
|
assert conn.execute(f"SELECT COUNT(*) n FROM {t} WHERE collection_id='ecomm'").fetchone()["n"] == 0, t
|
||||||
|
else:
|
||||||
|
assert conn.execute(f"SELECT COUNT(*) n FROM {t} WHERE project_id='ecomm'").fetchone()["n"] == 0, t
|
||||||
|
# The non-keyed descendant (thread_messages on ecomm's thread) is gone too.
|
||||||
|
assert conn.execute("SELECT COUNT(*) n FROM thread_messages").fetchone()["n"] == 1 # only ohm's remains
|
||||||
|
|
||||||
|
# ohm (the default) is fully intact.
|
||||||
|
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
|
||||||
|
assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None
|
||||||
|
assert conn.execute("SELECT COUNT(*) n FROM cached_rfcs WHERE slug='human'").fetchone()["n"] == 1
|
||||||
|
assert conn.execute("SELECT COUNT(*) n FROM threads WHERE rfc_slug='human'").fetchone()["n"] == 1
|
||||||
|
|
||||||
|
# FK integrity intact after the prune.
|
||||||
|
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_is_noop_when_all_projects_present(monkeypatch):
|
||||||
|
cfg, conn = _two_project_db(monkeypatch, default_id="ohm")
|
||||||
|
assert projects.prune_absent_projects(cfg, {"ohm", "ecomm"}) == []
|
||||||
|
assert conn.execute("SELECT COUNT(*) n FROM projects").fetchone()["n"] == 2
|
||||||
|
assert conn.execute("SELECT COUNT(*) n FROM cached_rfcs").fetchone()["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_never_removes_the_default_project(monkeypatch):
|
||||||
|
# Even with an EMPTY present set (which the real caller can't produce —
|
||||||
|
# parse_registry rejects an empty registry), the default project survives.
|
||||||
|
cfg, conn = _two_project_db(monkeypatch, default_id="ohm")
|
||||||
|
pruned = projects.prune_absent_projects(cfg, set())
|
||||||
|
assert "ohm" not in pruned and "ecomm" in pruned
|
||||||
|
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
|
||||||
|
assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None
|
||||||
|
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_registry_is_rejected_so_prune_is_never_reached():
|
||||||
|
# The structural safety guard: a parse that would yield no projects raises,
|
||||||
|
# so refresh_registry never reaches the prune with an empty present set.
|
||||||
|
with pytest.raises(registry.RegistryError):
|
||||||
|
registry.parse_registry("deployment:\n name: x\nprojects: []\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_incidental_refresh_does_not_prune(app_with_fake_gitea): # noqa: F811
|
||||||
|
"""An incidental re-mirror (the registry webhook, prune=False) must NOT prune
|
||||||
|
a project absent from projects.yaml — only the full-reconcile triggers
|
||||||
|
(startup + periodic sweep) prune. Guards the scoping decision so an in-app
|
||||||
|
refresh can't wipe a project."""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# A project present in the DB but not in the fake registry's projects.yaml.
|
||||||
|
db.conn().execute(
|
||||||
|
"INSERT INTO projects (id,name,content_repo,visibility) "
|
||||||
|
"VALUES ('ghost','Ghost','ghost-content','public')"
|
||||||
|
)
|
||||||
|
body = _json.dumps({"repository": {"full_name": "wiggleverse/registry"}}).encode()
|
||||||
|
secret = "test-webhook-secret-for-signature-verification"
|
||||||
|
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
r = client.post(
|
||||||
|
"/api/webhooks/gitea",
|
||||||
|
content=body,
|
||||||
|
headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sig,
|
||||||
|
"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
# The incidental re-mirror left the ghost in place (prune is off here).
|
||||||
|
assert db.conn().execute("SELECT 1 FROM projects WHERE id='ghost'").fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
# app_with_fake_gitea fixture import for the integration test above.
|
||||||
|
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: E402,F401
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
Generated
+159
-1282
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.52.3",
|
"version": "0.55.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -36,6 +36,6 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -448,11 +448,27 @@ export async function listModels(slug) {
|
|||||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/models`))
|
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`))
|
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(
|
return jsonOrThrow(await fetch(
|
||||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
|
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ export default function RFCView({ viewer }) {
|
|||||||
// surface to expose.
|
// surface to expose.
|
||||||
const editorRef = useRef(null)
|
const editorRef = useRef(null)
|
||||||
const originalSourceLinesRef = useRef([])
|
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('')
|
const [editorContent, setEditorContent] = useState('')
|
||||||
// Mirror of the live CM6 doc for the Contribute-mode preview pane,
|
// Mirror of the live CM6 doc for the Contribute-mode preview pane,
|
||||||
// debounced so the preview doesn't re-render on every keystroke.
|
// debounced so the preview doesn't re-render on every keystroke.
|
||||||
@@ -221,8 +230,8 @@ export default function RFCView({ viewer }) {
|
|||||||
setSelection(null)
|
setSelection(null)
|
||||||
setMode('discuss')
|
setMode('discuss')
|
||||||
|
|
||||||
getRFCMain(slug).then(setMainView).catch(err => setError(err.message))
|
getRFCMain(slug, pid, cid).then(setMainView).catch(err => setError(err.message))
|
||||||
getBranch(slug, branchParam)
|
getBranch(slug, branchParam, pid, cid)
|
||||||
.then(view => {
|
.then(view => {
|
||||||
setBranchView(view)
|
setBranchView(view)
|
||||||
setEditorContent(view.body || '')
|
setEditorContent(view.body || '')
|
||||||
@@ -231,13 +240,27 @@ export default function RFCView({ viewer }) {
|
|||||||
setChanges(view.changes || [])
|
setChanges(view.changes || [])
|
||||||
})
|
})
|
||||||
.catch(err => setError(err.message))
|
.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.
|
// Load chat messages whenever the branch's main thread id resolves.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!branchView?.main_thread_id) return
|
if (!branchView?.main_thread_id) return
|
||||||
loadAllMessages(slug, branchParam, branchView.threads).then(setMessages)
|
loadAllMessages(slug, branchParam, branchView.threads).then(loaded => {
|
||||||
}, [branchView?.main_thread_id, slug, branchParam])
|
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}
|
// Selection wiring (§8.12). MarkdownPreview reports {text, coords}
|
||||||
// sourced from window.getSelection(); the SelectionTooltip
|
// sourced from window.getSelection(); the SelectionTooltip
|
||||||
@@ -266,7 +289,7 @@ export default function RFCView({ viewer }) {
|
|||||||
paragraphCount: manualPending?.paragraphCount || 1,
|
paragraphCount: manualPending?.paragraphCount || 1,
|
||||||
})
|
})
|
||||||
if (!res.noop) {
|
if (!res.noop) {
|
||||||
const fresh = await getBranch(slug, branchParam)
|
const fresh = await getBranch(slug, branchParam, pid, cid)
|
||||||
setBranchView(fresh)
|
setBranchView(fresh)
|
||||||
setChanges(fresh.changes || [])
|
setChanges(fresh.changes || [])
|
||||||
setPreviewContent(fresh.body || '')
|
setPreviewContent(fresh.body || '')
|
||||||
@@ -334,8 +357,12 @@ export default function RFCView({ viewer }) {
|
|||||||
}, [manualCountdown, flushManualBuffer])
|
}, [manualCountdown, flushManualBuffer])
|
||||||
|
|
||||||
// ── Start contributing ─────────────────────────────────────────────────
|
// ── 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 () => {
|
const handleStartContributing = useCallback(async () => {
|
||||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
if (!viewer) { window.location.href = '/auth/login'; return null }
|
||||||
if (branchParam === 'main') {
|
if (branchParam === 'main') {
|
||||||
try {
|
try {
|
||||||
// §9.5 dispatch: super-drafts cut a meta-repo edit branch via
|
// §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 startEditBranch(slug)
|
||||||
: await promoteToBranch(slug)
|
: await promoteToBranch(slug)
|
||||||
setSearchParams({ branch: branch_name })
|
setSearchParams({ branch: branch_name })
|
||||||
|
return branch_name
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Non-main: pure mode flip per §8.14.
|
// Non-main: pure mode flip per §8.14.
|
||||||
if (pendingDiscussChanges.length > 0) {
|
if (pendingDiscussChanges.length > 0) {
|
||||||
setPendingDiscussChanges([])
|
setPendingDiscussChanges([])
|
||||||
}
|
}
|
||||||
setMode('contribute')
|
setMode('contribute')
|
||||||
|
return branchParam
|
||||||
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams, isSuperDraft])
|
}, [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) ───────────────
|
// ── Submit a chat turn (prompt bar or selection tooltip) ───────────────
|
||||||
const submitChatTurn = useCallback(async (text, quote) => {
|
const submitChatTurn = useCallback(async (text, quote) => {
|
||||||
if (!branchView?.main_thread_id || isStreaming) return
|
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.
|
// 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 || [])
|
setChanges(fresh.changes || [])
|
||||||
// If we're in discuss mode and the new turn produced pending AI changes,
|
// If we're in discuss mode and the new turn produced pending AI changes,
|
||||||
// surface them as discuss-mode buffered count.
|
// 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])
|
}, [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 handlePrompt = useCallback((text, sel) => {
|
||||||
const quote = sel?.text || null
|
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(text, quote)
|
||||||
}, [submitChatTurn])
|
}, [branchParam, handleMainAsk, submitChatTurn])
|
||||||
|
|
||||||
const handleTooltipAsk = useCallback(async (textOrNull, quote) => {
|
const handleTooltipAsk = useCallback(async (textOrNull, quote) => {
|
||||||
if (textOrNull === null) { setSelection(null); return }
|
if (textOrNull === null) { setSelection(null); return }
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
|
if (branchParam === 'main') { await handleMainAsk(textOrNull, quote); return }
|
||||||
await submitChatTurn(textOrNull, quote)
|
await submitChatTurn(textOrNull, quote)
|
||||||
}, [submitChatTurn])
|
}, [branchParam, handleMainAsk, submitChatTurn])
|
||||||
|
|
||||||
const handleTooltipFlag = useCallback(async (label, quote) => {
|
const handleTooltipFlag = useCallback(async (label, quote) => {
|
||||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||||
@@ -447,7 +498,7 @@ export default function RFCView({ viewer }) {
|
|||||||
anchor_payload: { quote },
|
anchor_payload: { quote },
|
||||||
label,
|
label,
|
||||||
})
|
})
|
||||||
const fresh = await getBranch(slug, branchParam)
|
const fresh = await getBranch(slug, branchParam, pid, cid)
|
||||||
setBranchView(fresh)
|
setBranchView(fresh)
|
||||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -463,7 +514,7 @@ export default function RFCView({ viewer }) {
|
|||||||
// body. The proper tracked-changes overlay lives in Phase 3's
|
// body. The proper tracked-changes overlay lives in Phase 3's
|
||||||
// preview pane; with CM6 as the source editor there is no HTML
|
// preview pane; with CM6 as the source editor there is no HTML
|
||||||
// surface to inject `<span class="tracked-*">` into.
|
// surface to inject `<span class="tracked-*">` into.
|
||||||
const fresh = await getBranch(slug, branchParam)
|
const fresh = await getBranch(slug, branchParam, pid, cid)
|
||||||
setBranchView(fresh)
|
setBranchView(fresh)
|
||||||
setChanges(fresh.changes || [])
|
setChanges(fresh.changes || [])
|
||||||
setEditorContent(fresh.body || '')
|
setEditorContent(fresh.body || '')
|
||||||
@@ -477,7 +528,7 @@ export default function RFCView({ viewer }) {
|
|||||||
const handleDecline = useCallback(async (changeId) => {
|
const handleDecline = useCallback(async (changeId) => {
|
||||||
try {
|
try {
|
||||||
await apiDecline(slug, branchParam, changeId)
|
await apiDecline(slug, branchParam, changeId)
|
||||||
const fresh = await getBranch(slug, branchParam)
|
const fresh = await getBranch(slug, branchParam, pid, cid)
|
||||||
setChanges(fresh.changes || [])
|
setChanges(fresh.changes || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -487,7 +538,7 @@ export default function RFCView({ viewer }) {
|
|||||||
const handleReask = useCallback(async (changeId) => {
|
const handleReask = useCallback(async (changeId) => {
|
||||||
try {
|
try {
|
||||||
await reaskChange(slug, branchParam, changeId)
|
await reaskChange(slug, branchParam, changeId)
|
||||||
const fresh = await getBranch(slug, branchParam)
|
const fresh = await getBranch(slug, branchParam, pid, cid)
|
||||||
setBranchView(fresh)
|
setBranchView(fresh)
|
||||||
setChanges(fresh.changes || [])
|
setChanges(fresh.changes || [])
|
||||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||||
@@ -499,7 +550,7 @@ export default function RFCView({ viewer }) {
|
|||||||
const handleResolveThread = useCallback(async (threadId) => {
|
const handleResolveThread = useCallback(async (threadId) => {
|
||||||
try {
|
try {
|
||||||
await resolveThread(slug, branchParam, threadId)
|
await resolveThread(slug, branchParam, threadId)
|
||||||
const fresh = await getBranch(slug, branchParam)
|
const fresh = await getBranch(slug, branchParam, pid, cid)
|
||||||
setBranchView(fresh)
|
setBranchView(fresh)
|
||||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -977,7 +1028,7 @@ export default function RFCView({ viewer }) {
|
|||||||
current={branchView.visibility}
|
current={branchView.visibility}
|
||||||
onClose={() => setShowVisibility(false)}
|
onClose={() => setShowVisibility(false)}
|
||||||
onSaved={async () => {
|
onSaved={async () => {
|
||||||
const fresh = await getBranch(slug, branchParam)
|
const fresh = await getBranch(slug, branchParam, pid, cid)
|
||||||
setBranchView(fresh)
|
setBranchView(fresh)
|
||||||
setShowVisibility(false)
|
setShowVisibility(false)
|
||||||
}}
|
}}
|
||||||
@@ -993,7 +1044,7 @@ export default function RFCView({ viewer }) {
|
|||||||
setShowGraduateDialog(false)
|
setShowGraduateDialog(false)
|
||||||
// The catalog row and the RFC view now reflect `active`.
|
// The catalog row and the RFC view now reflect `active`.
|
||||||
getRFC(pid, slug, cid).then(setEntry).catch(() => {})
|
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)
|
setShowMetadataPane(false)
|
||||||
// Refresh main view so the new open PR surfaces in the
|
// Refresh main view so the new open PR surfaces in the
|
||||||
// breadcrumb meta count immediately.
|
// breadcrumb meta count immediately.
|
||||||
getRFCMain(slug).then(setMainView).catch(() => {})
|
getRFCMain(slug, pid, cid).then(setMainView).catch(() => {})
|
||||||
navigate(entryPrPath(pid, slug, prNumber))
|
navigate(entryPrPath(pid, slug, prNumber))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user