Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e2214213 | |||
| 83eafe72ee | |||
| dd9ceff69e | |||
| 52f465b4dd | |||
| 2fc7029bd9 | |||
| 9e1b7ce34f | |||
| cbaba76345 | |||
| 620926b834 | |||
| 4ac3955056 | |||
| 7886840362 | |||
| 8eee907893 | |||
| a2b55f94ce | |||
| edbf68909a | |||
| 282706d7ef | |||
| eaf69cd05c | |||
| 0d2fdfacf2 | |||
| abd17a6cc8 | |||
| 46c957cff5 | |||
| d687a65470 | |||
| aee9b582e5 | |||
| 734290f344 | |||
| 49981e2d6e | |||
| 7ece6d348b | |||
| 6bb6d654fa | |||
| 276a625997 | |||
| ae3afe2f48 | |||
| ae083bfcaa | |||
| bcce40d2cb | |||
| 7b269e11c4 | |||
| 27061c30b0 | |||
| 14ea3c0cce | |||
| 644bf35d89 | |||
| 3636fa5afd | |||
| 1bcf8aa77e | |||
| e336e31812 | |||
| 98c276a662 | |||
| f05ee59763 | |||
| b1acc2382d | |||
| 5cb5f4a4a2 | |||
| 36cb6187eb | |||
| 677c5eb72f | |||
| 077563ea47 | |||
| dc5345cef4 |
+307
@@ -23,6 +23,313 @@ 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.52.0 — 2026-06-07
|
||||||
|
|
||||||
|
**Minor — deployed-environment E2E harness (new opt-in test-auth surface;
|
||||||
|
no operator action required for existing deployments).**
|
||||||
|
|
||||||
|
The §9 pipeline's middle stage — running the Playwright E2E suite against
|
||||||
|
a *deployed* pre-prod host — was unreachable because the suite was bound
|
||||||
|
to local-only scaffolding (a docker-seeded faceted collection, a
|
||||||
|
SQLite-injected owner, and Mailpit for the OTC code). This release makes
|
||||||
|
the same suite run green against a deployed host.
|
||||||
|
|
||||||
|
- **New gated test-auth endpoint, `POST /auth/test/login`.** Mints an
|
||||||
|
authenticated **owner** session for a single pre-configured identity, to
|
||||||
|
let the E2E suite sign in without a mail sink. It is **fail-closed**:
|
||||||
|
returns `404` unless **both** `E2E_TEST_AUTH_SECRET` and
|
||||||
|
`E2E_TEST_AUTH_EMAIL` are set (so it is inert in production, which sets
|
||||||
|
neither); requires the secret in the `X-Test-Auth-Secret` header
|
||||||
|
(constant-time compare; wrong/absent → `404`); only mints the one
|
||||||
|
configured email (any other → `403`); and logs a `WARNING` at startup
|
||||||
|
when enabled. **Leave both env vars unset on production.**
|
||||||
|
- **E2E harness parameterized for deployed runs.** `e2e/lib/auth.js`
|
||||||
|
branches on `E2E_TEST_AUTH_SECRET` (deployed test-login vs. the local
|
||||||
|
Mailpit OTC path); `OWNER_EMAIL` reads `E2E_OWNER_EMAIL`. `BASE_URL` was
|
||||||
|
already honored. The local Tier-1 path is unchanged.
|
||||||
|
- **`testing/seed-ppe.sh`** seeds a dedicated, prod-untouching PPE
|
||||||
|
registry + content repo (faceted `bdd` collection) so the deployed
|
||||||
|
fixtures never leak onto real content.
|
||||||
|
|
||||||
|
Design note: `docs/design/2026-06-07-deployed-env-e2e-harness.md`.
|
||||||
|
|
||||||
|
## 0.51.1 — 2026-06-07
|
||||||
|
|
||||||
|
**Patch — two §22 correctness fixes (no operator action required).**
|
||||||
|
|
||||||
|
- **Faceted catalog / bulk bar now scope to the named collection in the URL.**
|
||||||
|
`useCollectionId` read the route param, but the catalog renders above the
|
||||||
|
`c/:collectionId` route, so it always fell back to the default collection —
|
||||||
|
the faceted filter (§22.4a SLICE-3) and bulk action bar (SLICE-5) never
|
||||||
|
scoped to a named, `fields:`-bearing collection in the deployed app. It now
|
||||||
|
resolves the `/c/<id>/` segment from the path. Caught by a new end-to-end
|
||||||
|
Playwright suite exercising the metadata UI against a real browser + Gitea.
|
||||||
|
|
||||||
|
- **Heal the migration-029 vs registry-mirror collection-id divergence.** On a
|
||||||
|
deployment that already held ≥2 projects when migration 029 ran, the default
|
||||||
|
project's collection was seeded as the project id while the mirror expects
|
||||||
|
`'default'`, so the mirror inserted a duplicate empty collection. A new
|
||||||
|
startup reconciler (`projects.reconcile_default_collection_id`, the
|
||||||
|
collection-grain twin of the §22.13 re-stamp) renames the divergent
|
||||||
|
collection to `'default'` before the mirror runs. Idempotent; a no-op on
|
||||||
|
fresh / single-project / already-aligned deployments (incl. the default
|
||||||
|
`document` deployment).
|
||||||
|
|
||||||
|
Also: the per-IP auth rate-limiter budgets are now env-overridable
|
||||||
|
(`RATELIMIT_OTC_REQUEST_MAX` / `_VERIFY_MAX` / `_CHECK_MAX`) for test/PPE stacks
|
||||||
|
that drive auth from one IP; production keeps the secure defaults. The Tier-1
|
||||||
|
test harness is brought current with the §22 three-tier app.
|
||||||
|
|
||||||
|
## 0.51.0 — 2026-06-07
|
||||||
|
|
||||||
|
**Minor — bulk tag/untag metadata edit (§22.4a SLICE-5).**
|
||||||
|
An authorized user can now apply one metadata field change to many catalog
|
||||||
|
entries at once. A new endpoint
|
||||||
|
`POST /api/projects/{id}/collections/{cid}/meta/bulk` takes
|
||||||
|
`{slugs, op: set|add|remove, field, value}`, validates each entry against the
|
||||||
|
collection's `fields:` schema at the write boundary (INV-4), writes the passing
|
||||||
|
entries' `<slug>.meta.yaml` **sidecars** in a **single commit** (D7: bulk = one
|
||||||
|
commit, reusing the SLICE-4 sidecar write-through), re-ingests, and returns
|
||||||
|
`{applied, rejected}` so partial failures (missing entry, invalid value) are
|
||||||
|
reported without sinking the batch. `set` works for any field; `add`/`remove`
|
||||||
|
operate on a `tags`-type field. In the faceted catalog, contributors now get a
|
||||||
|
per-row selection checkbox and a sticky bulk action bar (one "Set ▾" control per
|
||||||
|
enum field, add/remove-tag for tags fields); a successful apply toasts the
|
||||||
|
applied/skipped counts and refreshes the list. This completes PUC-2 of the
|
||||||
|
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||||
|
design (§5.3, §6.4/§6.5).
|
||||||
|
|
||||||
|
No deployment action required. The endpoint and the bulk bar are additive and
|
||||||
|
opt-in per collection: a collection with no `fields:` block (INV-5 — the
|
||||||
|
default `document` collection) shows no selection UI and the endpoint returns
|
||||||
|
`422`, so an N=1 deployment sees zero change.
|
||||||
|
|
||||||
|
## 0.50.0 — 2026-06-07
|
||||||
|
|
||||||
|
**Minor — single-entry metadata edit + sidecar-aware writes (§22.4a SLICE-4).**
|
||||||
|
An authorized user can now edit one entry's schema-defined metadata directly from
|
||||||
|
its detail view. A new endpoint
|
||||||
|
`POST /api/projects/{id}/collections/{cid}/rfcs/{slug}/meta` takes `{values:{…}}`,
|
||||||
|
validates them against the collection's `fields:` schema at the write boundary
|
||||||
|
(INV-4), writes them to the entry's `<slug>.meta.yaml` **sidecar** with a **direct
|
||||||
|
commit** (D7 — no PR for authorized roles), and re-ingests. A legacy entry is
|
||||||
|
**lazy-migrated** to a clean body-only `.md` + sidecar on its first metadata edit.
|
||||||
|
The detail view renders one control per declared field (enum→select, tags→chips,
|
||||||
|
text→input), read-only without contribute access; `GET` on an entry now returns
|
||||||
|
its `meta` mapping and a `can_edit_meta` capability. This completes PUC-1 of the
|
||||||
|
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||||
|
design (§7.2); bulk edit (SLICE-5) follows.
|
||||||
|
|
||||||
|
Carried from SLICE-1: **every entry write path is now sidecar-aware.** Graduation,
|
||||||
|
ownership claim, retire/un-retire, mark-reviewed, the body-edit accept/flush
|
||||||
|
wrappers, and the PR-replay wrappers all dual-read an entry (so a migrated
|
||||||
|
body-only `.md` no longer crashes `entry.parse`) and write metadata changes to the
|
||||||
|
sidecar, keeping the `.md` body pure (INV-2) and never re-growing frontmatter; a
|
||||||
|
legacy entry lazy-migrates on its first metadata-bearing write. With the write
|
||||||
|
paths safe, the **Owner-gated collection-migration endpoint**
|
||||||
|
`POST /api/projects/{id}/collections/{cid}/migrate` now ships (PUC-5): it converts
|
||||||
|
a collection's legacy-frontmatter entries to clean body-only `.md` + sidecars in
|
||||||
|
one commit, idempotently.
|
||||||
|
|
||||||
|
Non-breaking and opt-in (INV-5): a collection with **no `fields:`** exposes no
|
||||||
|
edit panel and the edit endpoint returns 422 ("no editable fields"); the §22.13
|
||||||
|
default `document` collection declares none — so **N=1 deployments see no change**.
|
||||||
|
|
||||||
|
**Upgrade steps**
|
||||||
|
|
||||||
|
- No schema migration. The edit and migrate endpoints are additive; the sidecar
|
||||||
|
storage layer (mig 033) and `meta_json` index (mig 034) shipped in 0.47.0/0.49.0.
|
||||||
|
- A deployment adopts single-entry editing by declaring an `enum`/`tags`/`text`
|
||||||
|
`fields:` block in a collection's `.collection.yaml` (SLICE-2). Contributors+
|
||||||
|
on that collection (§22 Part B / S3 scope roles) may then edit from the detail
|
||||||
|
view; the change is a direct commit to the entry's sidecar.
|
||||||
|
- Operators MAY run `POST …/collections/{cid}/migrate` (collection Owner only) to
|
||||||
|
convert a collection's existing entries to clean body-only docs + sidecars up
|
||||||
|
front. It is idempotent and safe to re-run; dual-read means an un-migrated
|
||||||
|
collection keeps working, and entries lazy-migrate on their first metadata edit
|
||||||
|
regardless. **No data migration is required.**
|
||||||
|
|
||||||
|
## 0.49.0 — 2026-06-07
|
||||||
|
|
||||||
|
**Minor — faceted left-pane filtering (§22.4a SLICE-3).** A collection that
|
||||||
|
declares a metadata field schema (`fields:`, SLICE-2) now gets a **faceted
|
||||||
|
catalog left pane**: one collapsible filter group per `enum`/`tags` field plus
|
||||||
|
state, each with per-value **result counts** and multi-select checkboxes, and a
|
||||||
|
"filter values…" search box on `tags` groups so they stay usable at many values.
|
||||||
|
Filters compose — OR within a field, AND across fields — and a "malformed
|
||||||
|
metadata only" toggle surfaces entries failing their schema (INV-3). This is
|
||||||
|
SLICE-3 of the
|
||||||
|
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||||
|
design (§7.2); the single-entry and bulk metadata-edit UIs (SLICE-4/5) follow.
|
||||||
|
|
||||||
|
The collection-scoped list endpoint
|
||||||
|
(`GET /api/projects/{id}/collections/{cid}/rfcs`, and the project-scoped
|
||||||
|
default-collection alias) now honours filter query params
|
||||||
|
(`?priority=P0&tags=checkout&state=active&malformed=true`), returns a
|
||||||
|
`facets: {field → {value → count}}` block with drill-down counts, and includes
|
||||||
|
each entry's metadata `meta` mapping; an unknown filter field is a 400. Migration
|
||||||
|
034 adds the additive `cached_rfcs.meta_json` column that persists per-entry
|
||||||
|
metadata values for the index.
|
||||||
|
|
||||||
|
Non-breaking and opt-in (INV-5): a collection with **no `fields:`** keeps the
|
||||||
|
existing state-chip catalog unchanged, and the §22.13 default `document`
|
||||||
|
collection declares none — so **N=1 deployments see no change**. The unscoped
|
||||||
|
cross-collection `GET /api/rfcs` is untouched.
|
||||||
|
|
||||||
|
**Upgrade steps**
|
||||||
|
|
||||||
|
- Migration 034 (`cached_rfcs.meta_json`) applies automatically on startup
|
||||||
|
(additive, nullable). It is populated lazily as the corpus reconciler /
|
||||||
|
content-repo webhooks re-ingest each collection; until an entry is re-ingested
|
||||||
|
its `meta_json` is NULL and it contributes no facet values. Operators wanting
|
||||||
|
facets populated immediately MAY trigger a corpus refresh (the reconciler sweep
|
||||||
|
on next startup does this). **No data migration is required.**
|
||||||
|
- A deployment opts a collection into faceting by declaring an `enum`/`tags`
|
||||||
|
`fields:` block in its `.collection.yaml` (SLICE-2). A collection with no
|
||||||
|
`fields:` keeps the existing state-chip catalog unchanged.
|
||||||
|
|
||||||
|
## 0.48.0 — 2026-06-07
|
||||||
|
|
||||||
|
**Minor — collection field schema + central validation (§22.4a SLICE-2).** A
|
||||||
|
collection can now declare a small **field schema** in its `.collection.yaml`
|
||||||
|
(`fields:` block), so its entries carry structured, typed metadata — `priority`,
|
||||||
|
`tags`, and any custom fields the deployment defines. The schema is mirrored into
|
||||||
|
the collection record and served on the collection API; a new central validator
|
||||||
|
checks each entry's stored values against it. This is SLICE-2 of the
|
||||||
|
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||||
|
design (§7.2), building on the SLICE-1 sidecars; faceted filtering (SLICE-3) and
|
||||||
|
the edit UIs (SLICE-4/5) follow.
|
||||||
|
|
||||||
|
Non-breaking and opt-in: a collection with **no `fields:`** behaves exactly as
|
||||||
|
before (INV-5), and the §22.13 default `document` collection declares none
|
||||||
|
(**N=1 sees no change**). No DB migration — the normalized schema rides in the
|
||||||
|
existing `collections.config_json` column.
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- **`app/metadata_schema.py`** — the one place that knows a collection's field
|
||||||
|
shapes. `parse_fields(raw)` normalizes a `.collection.yaml` `fields:` block
|
||||||
|
**leniently** (INV-3): a bad block or a bad field def (non-mapping, unknown
|
||||||
|
type, `enum` without a non-empty `values:` list) is skipped with a warning,
|
||||||
|
never fatal — a typo in one field can't drop the whole collection from the
|
||||||
|
mirror. `validate(values, fields)` returns advisory `Problem`s (empty =
|
||||||
|
clean). v1 field types: **`enum`** (single value, controlled by a required
|
||||||
|
`values:`), **`tags`** (a list; free-form unless `values:` given), **`text`**
|
||||||
|
(a free string). `ref` / `multi-enum` are deferred (design §2). Keys an entry
|
||||||
|
carries that the schema does not declare ride along untouched, never flagged
|
||||||
|
(INV-7).
|
||||||
|
- **Registry ingest** (`registry.parse_collection_manifest`) reads the `fields:`
|
||||||
|
block into the collection config (→ `config_json`), beside `enabled_models`.
|
||||||
|
- **Collection read + API** (`collections.get_collection`) unpacks the schema
|
||||||
|
and `GET /api/projects/{id}/collections/{cid}` serves it as `fields` (`null`
|
||||||
|
when unset).
|
||||||
|
- **Advisory validation at ingest** — the corpus mirror
|
||||||
|
(`cache._refresh_collection_corpus`) validates each entry against its
|
||||||
|
collection's schema and flags a violation as `metadata_malformed` **without
|
||||||
|
blocking the read** (INV-3), OR-ed onto the SLICE-1 sidecar-syntax check.
|
||||||
|
Write-boundary **enforcement** (a 422 on the metadata-edit endpoints) lands
|
||||||
|
with those endpoints in SLICE-4/5.
|
||||||
|
|
||||||
|
### Upgrade steps (0.47.0 → 0.48.0)
|
||||||
|
|
||||||
|
- **No migration, no operator action.** A deployment opts in per collection by
|
||||||
|
adding a `fields:` block to that collection's `.collection.yaml`; until it
|
||||||
|
does, behavior is byte-for-byte unchanged.
|
||||||
|
- A collection's field schema is edited in git for v1 (in-app schema management
|
||||||
|
is deferred, design D8). After editing `.collection.yaml`, the registry mirror
|
||||||
|
picks the schema up on its next webhook / reconciler sweep.
|
||||||
|
- No config change. No content change is required.
|
||||||
|
|
||||||
|
## 0.47.0 — 2026-06-07
|
||||||
|
|
||||||
|
**Minor — metadata sidecars: storage + dual-read + migration tool (§22.4a
|
||||||
|
SLICE-1).** Entry metadata can now live in a per-entry `<slug>.meta.yaml`
|
||||||
|
**sidecar**, with the `.md` kept as pure prose (INV-2). The corpus mirror reads
|
||||||
|
the sidecar when present and falls back to legacy top-of-document frontmatter
|
||||||
|
otherwise (**dual-read**, INV-6), so existing corpora load byte-identically.
|
||||||
|
This is SLICE-1 of the
|
||||||
|
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||||
|
design (§7.2); the collection `fields:` schema + validation (SLICE-2), faceted
|
||||||
|
filtering (SLICE-3), and the edit UIs (SLICE-4/5) follow.
|
||||||
|
|
||||||
|
Non-breaking and opt-in: a collection with no sidecars behaves exactly as
|
||||||
|
before, and the §22.13 default collection is untouched (**N=1 sees no change**).
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- **Dual-read parser** (`app/metadata.py`) — `read_entry(md, sidecar)` yields
|
||||||
|
identical records whether metadata comes from a sidecar or legacy
|
||||||
|
frontmatter (INV-6); a malformed sidecar never hard-fails a read — the entry
|
||||||
|
still loads and is flagged (INV-3). Unknown / forward-compat keys ride along
|
||||||
|
untouched through parse→serialize and the migration (INV-7;
|
||||||
|
`Entry.extra`).
|
||||||
|
- **Frontmatter→sidecar migration tool** (`metadata.migrate_collection`) — a
|
||||||
|
deterministic, idempotent tool that lifts a collection's legacy entries into
|
||||||
|
sidecars + body-only `.md`s in **one commit** (new Gitea `change_files`
|
||||||
|
batch); a fully-migrated collection is a no-op. The **operator trigger** for
|
||||||
|
it (an Owner-gated endpoint) is intentionally **deferred to SLICE-4**: the
|
||||||
|
propose/graduate/mark-reviewed/edit write paths still read `.md` frontmatter
|
||||||
|
directly, so they must become sidecar-aware before a corpus is migrated in
|
||||||
|
production. Until then the tool is shippable groundwork, not yet wired to a
|
||||||
|
production trigger (INV-8: the engine write paths are unchanged this slice).
|
||||||
|
- **Malformed-metadata flag** — migration `033_metadata_malformed.sql` adds
|
||||||
|
`cached_rfcs.metadata_malformed` (additive); the corpus mirror derives it and
|
||||||
|
the catalog + entry-detail APIs surface `metadata_malformed`. A degenerate
|
||||||
|
sidecar (malformed / empty / slug-less) never drops the entry — it loads with
|
||||||
|
its slug backstopped from the filename and is flagged (INV-3).
|
||||||
|
- **INV-7 at graduation** — graduation now carries an entry's unknown /
|
||||||
|
forward-compat frontmatter keys through the rebuild instead of dropping them.
|
||||||
|
|
||||||
|
### Upgrade steps (0.46.2 → 0.47.0)
|
||||||
|
|
||||||
|
- The framework **MUST** apply migration `033_metadata_malformed.sql` — it runs
|
||||||
|
automatically at startup (additive column, no rebuild, default `0`).
|
||||||
|
- **No operator action** otherwise. With no sidecars present (the default after
|
||||||
|
this upgrade) every corpus stays on the legacy frontmatter path, byte-for-byte
|
||||||
|
as before. The frontmatter→sidecar migration is **not** operator-triggerable
|
||||||
|
yet (its endpoint lands in SLICE-4); dual-read makes the storage change
|
||||||
|
invisible until then.
|
||||||
|
- No config change. No content change is required.
|
||||||
|
|
||||||
|
## 0.46.2 — 2026-06-07
|
||||||
|
|
||||||
|
**Patch — `SPEC.md` §22.4a contract amendment: entry metadata is
|
||||||
|
collection-configured, not type-driven (doc-only).** Reframes the binding
|
||||||
|
§22.4a "collection type" contract so a collection's entry **metadata schema**
|
||||||
|
is **collection-configured** — a `fields:` schema declared in
|
||||||
|
`.collection.yaml` plus per-entry `<slug>.meta.yaml` **sidecars** — rather than
|
||||||
|
a frontmatter schema hard-wired to the collection's `type`. Item 3's
|
||||||
|
**type-specific surfaces** (release planning for `specification`;
|
||||||
|
scenario/coverage views for `bdd`) are **deferred** to a future design; the
|
||||||
|
`bdd` coverage capability is recorded there as a future `ref`-field surface
|
||||||
|
rendered as hyperlinks (never fusing corpora across collections). What `type`
|
||||||
|
still selects is the entry-noun terminology (item 2, shipped v0.45.0) and the
|
||||||
|
default `initial_state` / review posture (§22.4b–c).
|
||||||
|
|
||||||
|
This is **SLICE-0** of the
|
||||||
|
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||||
|
design (§7.2) — the contract amendment that unblocks the build slices. It
|
||||||
|
supersedes the [per-type-surfaces](./docs/design/2026-06-06-per-type-surfaces.md)
|
||||||
|
draft (D11; banner already on that doc).
|
||||||
|
|
||||||
|
**No operator action; no schema or behavior change.** Per-type frontmatter
|
||||||
|
validation was never implemented — the engine treats every entry as markdown +
|
||||||
|
frontmatter regardless of type — so amending the contract changes no runtime
|
||||||
|
behavior, no migration, and no deployment config. Sidecar storage, the
|
||||||
|
collection `fields:` schema, validation, and the metadata UI arrive in later
|
||||||
|
slices (SLICE-1+).
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- **`SPEC.md` §22.4a** — reframed: `type` selects terminology + default
|
||||||
|
`initial_state`/review posture; entry metadata is collection-configured
|
||||||
|
(`fields:` + `<slug>.meta.yaml` sidecars); type surfaces deferred. The
|
||||||
|
`document`/`specification`/`bdd` bullets updated accordingly; a metadata
|
||||||
|
amendment blockquote records the supersession.
|
||||||
|
- **`SPEC.md` §2 and §22 forward-pointers** — the two amendment blockquotes
|
||||||
|
that said "entry frontmatter schema is type-dependent" now read
|
||||||
|
"collection-configured, not type-driven (§22.4a, as amended)".
|
||||||
|
|
||||||
## 0.46.1 — 2026-06-06
|
## 0.46.1 — 2026-06-06
|
||||||
|
|
||||||
**Patch — migration 029 hardening for the §22.13 re-stamp aftermath.** Fixes a
|
**Patch — migration 029 hardening for the §22.13 re-stamp aftermath.** Fixes a
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install
|
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install e2e-fresh
|
||||||
|
|
||||||
|
# Two-phase: run the Gitea seed to completion FIRST so it writes the bot token /
|
||||||
|
# OAuth creds into generated/.env.tier1.generated, THEN create the backend/web —
|
||||||
|
# compose snapshots env_file at container-create time, so the backend must be
|
||||||
|
# created after the seed has populated it. The touch seeds an empty placeholder
|
||||||
|
# for compose's up-front env_file existence check on a clean checkout.
|
||||||
tier1-up:
|
tier1-up:
|
||||||
|
touch testing/generated/.env.tier1.generated
|
||||||
|
docker compose -f testing/docker-compose.yml up --build -d gitea-seed
|
||||||
|
docker compose -f testing/docker-compose.yml wait gitea-seed
|
||||||
docker compose -f testing/docker-compose.yml up --build -d
|
docker compose -f testing/docker-compose.yml up --build -d
|
||||||
|
|
||||||
tier1-down:
|
tier1-down:
|
||||||
|
touch testing/generated/.env.tier1.generated
|
||||||
docker compose -f testing/docker-compose.yml down -v
|
docker compose -f testing/docker-compose.yml down -v
|
||||||
|
|
||||||
tier1-logs:
|
tier1-logs:
|
||||||
@@ -17,3 +26,8 @@ e2e-install:
|
|||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
|
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
|
||||||
|
|
||||||
|
# Canonical run: the metadata specs mutate the seeded corpus (edit/bulk write
|
||||||
|
# real commits), so they assume a freshly-seeded stack. This brings the stack
|
||||||
|
# down, back up (re-seeds), and runs the suite once — the shape CI uses.
|
||||||
|
e2e-fresh: tier1-down tier1-up e2e
|
||||||
|
|||||||
@@ -121,9 +121,11 @@ live in the meta repo — they live in the app database (see §5).
|
|||||||
|
|
||||||
> **Three-tier amendment (v0.45.0 — see §22).** Slugs are unique **per
|
> **Three-tier amendment (v0.45.0 — see §22).** Slugs are unique **per
|
||||||
> collection** (§22.4): `model/intro` and `specs/intro` coexist. The entry
|
> collection** (§22.4): `model/intro` and `specs/intro` coexist. The entry
|
||||||
> frontmatter schema is **type-dependent** on the collection's `type`
|
> **metadata schema is collection-configured** (§22.4a, as amended by
|
||||||
> (§22.4a) — `document` keeps the fields below; `specification` and `bdd`
|
> v0.46.2) — each collection declares a `fields:` schema in its
|
||||||
> add their type metadata. The §2.3 `RFC-NNNN` `max+1` allocation is
|
> `.collection.yaml` and stores per-entry values in a `<slug>.meta.yaml`
|
||||||
|
> sidecar; the fields below are the `document` baseline, **not**
|
||||||
|
> type-driven. The §2.3 `RFC-NNNN` `max+1` allocation is
|
||||||
> **removed** (the slug is the identity); pre-change `id` values survive as
|
> **removed** (the slug is the identity); pre-change `id` values survive as
|
||||||
> frozen legacy labels. New `active`-entry frontmatter: `unreviewed` (bool)
|
> frozen legacy labels. New `active`-entry frontmatter: `unreviewed` (bool)
|
||||||
> and the `reviewed_at` / `reviewed_by` provenance pair (§22.4c).
|
> and the `reviewed_at` / `reviewed_by` provenance pair (§22.4c).
|
||||||
@@ -895,7 +897,13 @@ a hierarchy on the user that gets in the way of finding by title.
|
|||||||
- **Filter chip strip** — multi-select, AND-combined. Chips:
|
- **Filter chip strip** — multi-select, AND-combined. Chips:
|
||||||
`State: super-draft | active | withdrawn`, `My RFCs` (I'm an owner
|
`State: super-draft | active | withdrawn`, `My RFCs` (I'm an owner
|
||||||
or arbiter), `Has open PRs`, `Unclaimed` (super-drafts with empty
|
or arbiter), `Has open PRs`, `Unclaimed` (super-drafts with empty
|
||||||
`owners:`), `Tag: …`.
|
`owners:`), `Tag: …`. A collection that declares a metadata field
|
||||||
|
schema (§22.4a) replaces this chip strip with **faceted filter
|
||||||
|
groups** — one collapsible group per `enum`/`tags` field plus state,
|
||||||
|
each showing per-value result counts and multi-select checkboxes
|
||||||
|
(OR within a field, AND across fields); see the Configurable
|
||||||
|
Collection Metadata design. A collection with no `fields:` schema
|
||||||
|
keeps the chip strip described here unchanged.
|
||||||
|
|
||||||
### 7.2 The list rows
|
### 7.2 The list rows
|
||||||
|
|
||||||
@@ -5125,39 +5133,83 @@ never used for routing or lookup. New entries are never assigned one.
|
|||||||
|
|
||||||
### 22.4a Collection type
|
### 22.4a Collection type
|
||||||
|
|
||||||
|
> **Metadata amendment (v0.46.2 — Configurable Collection Metadata).** Item 1
|
||||||
|
> below originally made the **entry metadata schema type-driven** — a
|
||||||
|
> `document`/`specification`/`bdd` frontmatter schema baked into a per-type
|
||||||
|
> module. That is **superseded**: entry metadata is **collection-configured**,
|
||||||
|
> not type-driven. Each collection declares a `fields:` schema in its
|
||||||
|
> `.collection.yaml`, and per-entry values live in a `<slug>.meta.yaml`
|
||||||
|
> **sidecar** (the `.md` body stays pure prose; a parser reads the sidecar
|
||||||
|
> else legacy top-of-doc frontmatter). See the
|
||||||
|
> [Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||||
|
> design, which supersedes the
|
||||||
|
> [per-type-surfaces](./docs/design/2026-06-06-per-type-surfaces.md) draft
|
||||||
|
> (D11). Item 3's **type-specific surfaces** (release planning for
|
||||||
|
> `specification`; scenario/coverage views for `bdd`) are **deferred** to a
|
||||||
|
> future design; the `bdd` **coverage** capability is recorded there as a
|
||||||
|
> future `ref`-field surface that maps features to the spec entries they
|
||||||
|
> verify **as hyperlinks**, honoring the §22 rule against fusing corpora
|
||||||
|
> across collections. What `type` still selects is the **terminology** (item 2,
|
||||||
|
> the entry noun, shipped v0.45.0) and the **default `initial_state` / review
|
||||||
|
> posture** (§22.4b–c).
|
||||||
|
>
|
||||||
|
> **Shipped status.** The design lands incrementally: sidecar storage +
|
||||||
|
> dual-read (SLICE-1, v0.47.0), the collection `fields:` schema + central
|
||||||
|
> validation (SLICE-2, v0.48.0), faceted left-pane filtering (SLICE-3, v0.49.0),
|
||||||
|
> and **single-entry metadata edit (SLICE-4, v0.50.0)** — the direct-commit
|
||||||
|
> `POST …/rfcs/{slug}/meta` editor (contributor+, validated at the write
|
||||||
|
> boundary), the schema-driven detail panel, all entry **write paths made
|
||||||
|
> sidecar-aware** (a migrated body-only `.md` never re-grows frontmatter), and
|
||||||
|
> the Owner-gated `POST …/collections/{cid}/migrate` endpoint. **Bulk
|
||||||
|
> tag/untag (SLICE-5, v0.51.0)** completes the design: the
|
||||||
|
> `POST …/collections/{cid}/meta/bulk` endpoint (`{slugs, op: set|add|remove,
|
||||||
|
> field, value}`) applies one field change to many entries' sidecars in a
|
||||||
|
> **single commit** (D7), validated per entry at the write boundary with
|
||||||
|
> partial-rejection reporting (`{applied, rejected}`), plus the catalog's
|
||||||
|
> row multi-select + sticky bulk action bar. See §9.5 for the edit-metadata
|
||||||
|
> write-through this reuses.
|
||||||
|
|
||||||
Every collection declares a `type` in its `.collection.yaml` manifest
|
Every collection declares a `type` in its `.collection.yaml` manifest
|
||||||
(§22.2), chosen at creation and **immutable**: one of `document`,
|
(§22.2), chosen at creation and **immutable**: one of `document`,
|
||||||
`specification`, or `bdd`. Type does not change the engine — every type uses
|
`specification`, or `bdd`. Type does not change the engine — every type uses
|
||||||
the same content repo (§22.3), the same propose→branch→PR→discuss→graduate
|
the same content repo (§22.3), the same propose→branch→PR→discuss→graduate
|
||||||
lifecycle (§§9–13), the same threads, flags, and chat. Type selects exactly
|
lifecycle (§§9–13), the same threads, flags, and chat. Type selects:
|
||||||
three things:
|
|
||||||
|
|
||||||
1. the **entry frontmatter schema** the collection validates entries against (§2);
|
1. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog
|
||||||
2. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog labels);
|
labels) — the entry noun, shipped v0.45.0;
|
||||||
3. the set of **type-specific surfaces** layered on top of the shared §7 catalog.
|
2. the **default `initial_state`** a new entry lands in, and its review
|
||||||
|
posture (§22.4b, §22.4c).
|
||||||
|
|
||||||
Type-specific behavior is implemented as a per-type module the framework
|
Entry **metadata** is **not** selected by type — it is **collection-configured**
|
||||||
selects on `collection.type`; the engine itself treats every entry as
|
(a `.collection.yaml` `fields:` schema + per-entry `<slug>.meta.yaml`
|
||||||
markdown + frontmatter regardless of type. `type` is an **open set** in shape
|
sidecars; see the amendment above, the Configurable Collection Metadata
|
||||||
— a future type is a new module plus a new allowed enum value, no schema
|
design, and the §2 baseline). **Type-specific surfaces** layered on the shared
|
||||||
rebuild. The type names and their behavior are framework concepts (like role
|
§7 catalog are **deferred** to a future design.
|
||||||
names), not deployment content: a deployment picks which type each collection
|
|
||||||
is, but does not define or rename types.
|
Type-specific behavior, where it exists, is implemented as a per-type module
|
||||||
|
the framework selects on `collection.type`; the engine itself treats every
|
||||||
|
entry as markdown + a metadata sidecar regardless of type. `type` is an
|
||||||
|
**open set** in shape — a future type is a new module plus a new allowed enum
|
||||||
|
value, no schema rebuild. The type names and their behavior are framework
|
||||||
|
concepts (like role names), not deployment content: a deployment picks which
|
||||||
|
type each collection is, but does not define or rename types.
|
||||||
|
|
||||||
- **`document`** — long-form normative prose (OHM: a model of principles and
|
- **`document`** — long-form normative prose (OHM: a model of principles and
|
||||||
definitions). Frontmatter is the §2 baseline. No type-specific surfaces. The
|
definitions). Metadata is the §2 baseline; no collection-configured `fields:`
|
||||||
§22.13 generated default collection is a `document` collection, so the N=1
|
are required. The §22.13 generated default collection is a `document`
|
||||||
case is unchanged.
|
collection with no `fields:`, so the N=1 case is unchanged.
|
||||||
- **`specification`** — a versioned technical specification (this framework's
|
- **`specification`** — a versioned technical specification (this framework's
|
||||||
own `SPEC.md` is the archetype). Frontmatter adds spec metadata (`version`,
|
own `SPEC.md` is the archetype). A deployment that wants spec metadata
|
||||||
lifecycle `status` of draft/active/superseded, `supersedes`). Type-specific
|
(`version`, lifecycle `status` of draft/active/superseded, `supersedes`)
|
||||||
surface — **release planning:** group entries/changes into versioned
|
declares those as collection `fields:`. **Release planning** — grouping
|
||||||
releases with a changelog + §20-style upgrade-steps per release.
|
entries into versioned releases with a changelog + §20-style upgrade-steps —
|
||||||
|
is a **deferred** type surface.
|
||||||
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
|
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
|
||||||
Given/When/Then scenarios with acceptance criteria. Frontmatter adds feature
|
Given/When/Then scenarios with acceptance criteria. Feature metadata is
|
||||||
metadata and an optional link to the `specification` entries a feature
|
declared as collection `fields:`. The **scenario/acceptance view** and a
|
||||||
verifies. Type-specific surface: a scenario/acceptance view and a coverage
|
**coverage view** (mapping features to the spec entries they verify via a
|
||||||
view mapping features to the spec sections they exercise.
|
future `ref` field, rendered as hyperlinks — never fusing corpora across
|
||||||
|
collections) are **deferred** type surfaces.
|
||||||
|
|
||||||
### 22.4b Initial state of a new entry
|
### 22.4b Initial state of a new entry
|
||||||
|
|
||||||
@@ -5400,10 +5452,12 @@ The single-corpus sections defer to §22; the load-bearing reinterpretations:
|
|||||||
a project's repo (§22.3). The bot and app-owned-authorization paragraphs are
|
a project's repo (§22.3). The bot and app-owned-authorization paragraphs are
|
||||||
unchanged and now read org-wide.
|
unchanged and now read org-wide.
|
||||||
- **§2 Schema / §2.3 IDs.** Slugs are unique **per collection**; the entry
|
- **§2 Schema / §2.3 IDs.** Slugs are unique **per collection**; the entry
|
||||||
frontmatter schema is **type-dependent** (§22.4a). The `RFC-NNNN` `max+1`
|
**metadata schema is collection-configured**, not type-driven (§22.4a, as
|
||||||
allocation is **removed** — the slug is the identity (§22.4). New
|
amended by v0.46.2) — a `.collection.yaml` `fields:` schema + per-entry
|
||||||
`active`-entry fields: `unreviewed` (bool) and the `reviewed_at`/
|
`<slug>.meta.yaml` sidecars. The `RFC-NNNN` `max+1` allocation is
|
||||||
`reviewed_by` provenance pair (§22.4c).
|
**removed** — the slug is the identity (§22.4). New `active`-entry fields:
|
||||||
|
`unreviewed` (bool) and the `reviewed_at`/`reviewed_by` provenance pair
|
||||||
|
(§22.4c).
|
||||||
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition
|
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition
|
||||||
targets the collection's `initial_state` (§22.4b); a new `active
|
targets the collection's `initial_state` (§22.4b); a new `active
|
||||||
─[mark-reviewed, Owner]→ active` self-transition clears the `unreviewed`
|
─[mark-reviewed, Owner]→ active` self-transition clears the `unreviewed`
|
||||||
|
|||||||
@@ -130,3 +130,16 @@ CLOUDFLARE_TURNSTILE_SECRET=
|
|||||||
# config drift surfaces as a loud 500 rather than a silent abuse-
|
# config drift surfaces as a loud 500 rather than a silent abuse-
|
||||||
# defense disablement.
|
# defense disablement.
|
||||||
TURNSTILE_REQUIRED=false
|
TURNSTILE_REQUIRED=false
|
||||||
|
|
||||||
|
# --- Deployed-environment E2E test auth (v0.52.0) ---
|
||||||
|
# DANGER: NEVER set these on a production deployment. Together they
|
||||||
|
# enable `POST /auth/test/login`, which mints an authenticated OWNER
|
||||||
|
# session for the one configured email without any OTC/email round trip
|
||||||
|
# — it exists only to run the Playwright E2E suite against a deployed
|
||||||
|
# pre-prod (PPE) host that has no Mailpit sink. The route is fail-closed:
|
||||||
|
# it returns 404 unless BOTH vars below are set, requires the caller to
|
||||||
|
# present E2E_TEST_AUTH_SECRET in the `X-Test-Auth-Secret` header
|
||||||
|
# (constant-time compare), and only ever mints the single configured
|
||||||
|
# email (any other → 403). Leave BOTH unset everywhere except PPE.
|
||||||
|
# E2E_TEST_AUTH_EMAIL=e2e-owner@example.test
|
||||||
|
# E2E_TEST_AUTH_SECRET= # a Secret Manager ref on real deployments; never a literal here
|
||||||
|
|||||||
+67
-10
@@ -29,6 +29,7 @@ from . import (
|
|||||||
api_invitations,
|
api_invitations,
|
||||||
api_join_requests,
|
api_join_requests,
|
||||||
api_memberships,
|
api_memberships,
|
||||||
|
api_metadata,
|
||||||
api_notifications,
|
api_notifications,
|
||||||
api_prs,
|
api_prs,
|
||||||
auth,
|
auth,
|
||||||
@@ -41,6 +42,7 @@ from . import (
|
|||||||
docs_specs,
|
docs_specs,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
|
facets,
|
||||||
funder,
|
funder,
|
||||||
health,
|
health,
|
||||||
notify,
|
notify,
|
||||||
@@ -129,6 +131,8 @@ def make_router(
|
|||||||
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
||||||
# Slice 5: §13 graduation + §13.1 claim.
|
# Slice 5: §13 graduation + §13.1 claim.
|
||||||
router.include_router(api_graduation.make_router(config, gitea, bot))
|
router.include_router(api_graduation.make_router(config, gitea, bot))
|
||||||
|
# §22.4a SLICE-4/5: entry metadata edit + Owner-gated collection migrate.
|
||||||
|
router.include_router(api_metadata.make_router(config, gitea, bot))
|
||||||
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
||||||
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
||||||
router.include_router(api_notifications.make_router(config))
|
router.include_router(api_notifications.make_router(config))
|
||||||
@@ -651,6 +655,7 @@ def make_router(
|
|||||||
f"""
|
f"""
|
||||||
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
|
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
|
||||||
r.owners_json, r.arbiters_json, r.tags_json,
|
r.owners_json, r.arbiters_json, r.tags_json,
|
||||||
|
r.metadata_malformed,
|
||||||
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
|
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
|
||||||
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
|
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
|
||||||
WHERE r.state IN ('super-draft', 'active')
|
WHERE r.state IN ('super-draft', 'active')
|
||||||
@@ -684,6 +689,7 @@ def make_router(
|
|||||||
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
||||||
"starred_by_me": r["slug"] in starred,
|
"starred_by_me": r["slug"] in starred,
|
||||||
"has_open_prs": False, # wired in Slice 2 when per-RFC repos exist
|
"has_open_prs": False, # wired in Slice 2 when per-RFC repos exist
|
||||||
|
"metadata_malformed": bool(r["metadata_malformed"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {"items": items}
|
return {"items": items}
|
||||||
@@ -718,6 +724,9 @@ def make_router(
|
|||||||
(slug,),
|
(slug,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||||
|
# §22.4a SLICE-4: contributor+ on the entry's collection may edit metadata.
|
||||||
|
payload["can_edit_meta"] = bool(
|
||||||
|
auth.can_contribute_in_collection(viewer, auth.collection_of_rfc(slug)))
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -735,16 +744,41 @@ def make_router(
|
|||||||
raise HTTPException(404, "Not found")
|
raise HTTPException(404, "Not found")
|
||||||
|
|
||||||
def _list_rfcs_for_collection(
|
def _list_rfcs_for_collection(
|
||||||
collection_id: str, viewer, unreviewed: str | None
|
collection_id: str, viewer, unreviewed: str | None,
|
||||||
|
query_params=None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
viewer_id = viewer.user_id if viewer else None
|
viewer_id = viewer.user_id if viewer else None
|
||||||
|
# §22.4a SLICE-3: the collection's declared field schema drives the facet
|
||||||
|
# set (None when undeclared → no facets, INV-5).
|
||||||
|
col = collections_mod.get_collection(collection_id)
|
||||||
|
fields_schema = (col or {}).get("fields") or None
|
||||||
|
|
||||||
|
# Parse + validate filter selections from the query string. Unknown
|
||||||
|
# field → 400 (§6.4). `unreviewed` keeps its existing meaning; an
|
||||||
|
# empty-valued selection is ignored, not an error (plan decision 6).
|
||||||
|
selections: dict[str, set[str]] = {}
|
||||||
|
only_malformed = False
|
||||||
|
if query_params is not None:
|
||||||
|
allowed = facets.allowed_filter_keys(fields_schema)
|
||||||
|
facet_names = {n for n, _ in facets.facet_fields(fields_schema)}
|
||||||
|
for key in query_params.keys():
|
||||||
|
if key not in allowed:
|
||||||
|
raise HTTPException(400, f"unknown filter field {key!r}")
|
||||||
|
if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"):
|
||||||
|
only_malformed = True
|
||||||
|
for name in facet_names:
|
||||||
|
vals = {v for v in query_params.getlist(name) if v != ""}
|
||||||
|
if vals:
|
||||||
|
selections[name] = vals
|
||||||
|
|
||||||
unreviewed_clause = ""
|
unreviewed_clause = ""
|
||||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||||
rows = db.conn().execute(
|
rows = db.conn().execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT slug, title, state, rfc_id, repo,
|
SELECT slug, title, state, rfc_id, repo,
|
||||||
owners_json, arbiters_json, tags_json,
|
owners_json, arbiters_json, tags_json, metadata_malformed,
|
||||||
|
meta_json,
|
||||||
last_main_commit_at, last_entry_commit_at, updated_at
|
last_main_commit_at, last_entry_commit_at, updated_at
|
||||||
FROM cached_rfcs
|
FROM cached_rfcs
|
||||||
WHERE state IN ('super-draft', 'active')
|
WHERE state IN ('super-draft', 'active')
|
||||||
@@ -762,8 +796,16 @@ def make_router(
|
|||||||
(viewer_id, collection_id),
|
(viewer_id, collection_id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items = [
|
|
||||||
{
|
# Build entry dicts the facet helper understands (state + malformed +
|
||||||
|
# parsed meta), preserving SQL order.
|
||||||
|
entries = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
meta = json.loads(r["meta_json"]) if r["meta_json"] else {}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
meta = {}
|
||||||
|
entries.append({
|
||||||
"slug": r["slug"],
|
"slug": r["slug"],
|
||||||
"title": r["title"],
|
"title": r["title"],
|
||||||
"state": r["state"],
|
"state": r["state"],
|
||||||
@@ -775,10 +817,14 @@ def make_router(
|
|||||||
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
||||||
"starred_by_me": r["slug"] in starred,
|
"starred_by_me": r["slug"] in starred,
|
||||||
"has_open_prs": False,
|
"has_open_prs": False,
|
||||||
}
|
"metadata_malformed": bool(r["metadata_malformed"]),
|
||||||
for r in rows
|
"meta": meta,
|
||||||
]
|
})
|
||||||
return {"items": items}
|
|
||||||
|
filtered, facet_counts = facets.filter_and_count(
|
||||||
|
entries, fields_schema, selections, only_malformed=only_malformed
|
||||||
|
)
|
||||||
|
return {"items": filtered, "facets": facet_counts}
|
||||||
|
|
||||||
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
||||||
row = db.conn().execute(
|
row = db.conn().execute(
|
||||||
@@ -799,6 +845,9 @@ def make_router(
|
|||||||
(slug, collection_id),
|
(slug, collection_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||||
|
# §22.4a SLICE-4: contributor+ on the collection may edit metadata (INV-4).
|
||||||
|
payload["can_edit_meta"] = bool(
|
||||||
|
auth.can_contribute_in_collection(viewer, collection_id))
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/rfcs")
|
@router.get("/api/projects/{project_id}/rfcs")
|
||||||
@@ -810,7 +859,9 @@ def make_router(
|
|||||||
auth.require_project_readable(viewer, project_id)
|
auth.require_project_readable(viewer, project_id)
|
||||||
# §22 S1: the project-scoped route serves the default collection.
|
# §22 S1: the project-scoped route serves the default collection.
|
||||||
collection_id = collections_mod.default_collection_id(project_id)
|
collection_id = collections_mod.default_collection_id(project_id)
|
||||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
return _list_rfcs_for_collection(
|
||||||
|
collection_id, viewer, unreviewed, query_params=request.query_params
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||||
@@ -832,7 +883,9 @@ def make_router(
|
|||||||
_require_collection_in_project(collection_id, project_id)
|
_require_collection_in_project(collection_id, project_id)
|
||||||
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
||||||
auth.require_collection_readable(viewer, collection_id)
|
auth.require_collection_readable(viewer, collection_id)
|
||||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
return _list_rfcs_for_collection(
|
||||||
|
collection_id, viewer, unreviewed, query_params=request.query_params
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
||||||
async def get_collection_rfc(
|
async def get_collection_rfc(
|
||||||
@@ -1342,6 +1395,10 @@ def _serialize_rfc(row) -> dict[str, Any]:
|
|||||||
"arbiters": json.loads(row["arbiters_json"] or "[]"),
|
"arbiters": json.loads(row["arbiters_json"] or "[]"),
|
||||||
"tags": json.loads(row["tags_json"] or "[]"),
|
"tags": json.loads(row["tags_json"] or "[]"),
|
||||||
"body": row["body"] or "",
|
"body": row["body"] or "",
|
||||||
|
"metadata_malformed": bool(row["metadata_malformed"]),
|
||||||
|
# §22.4a SLICE-4: the full per-entry metadata mapping (known + custom
|
||||||
|
# fields) so the detail panel can render schema-driven controls.
|
||||||
|
"meta": json.loads(row["meta_json"] or "{}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+36
-21
@@ -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, models_resolver, projects as projects_mod
|
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 .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
|
||||||
@@ -40,6 +40,37 @@ log = logging.getLogger(__name__)
|
|||||||
RFC_FILE_PATH = "RFC.md"
|
RFC_FILE_PATH = "RFC.md"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# §22.4a SLICE-4: sidecar-aware body extract/wrap (pure, unit-testable)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_body_pure(rfc, file_contents: str, branch: str, *, is_meta: bool) -> str:
|
||||||
|
"""Editable body of an entry file. Meta-resident files carry a frontmatter
|
||||||
|
envelope (legacy) or are already body-only (migrated, §22.4a); per-RFC repo
|
||||||
|
files are body-only. Dual-read tolerant: a body-only `.md` returns as-is."""
|
||||||
|
if not is_meta:
|
||||||
|
return file_contents
|
||||||
|
return metadata_mod.strip_frontmatter(file_contents)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_body_pure(rfc, prior_contents: str, new_body: str, branch: str, *, is_meta: bool) -> str:
|
||||||
|
"""Inverse of `_extract_body_pure`. Under §22.4a the body lives in the `.md`
|
||||||
|
and metadata in the sidecar, so wrapping is identity for body-only files —
|
||||||
|
frontmatter is never re-grown here. A legacy un-migrated meta file still has
|
||||||
|
its metadata in the `.md` frontmatter (no sidecar yet), so preserve it rather
|
||||||
|
than silently dropping it on a pure body edit; it is migrated to body-only on
|
||||||
|
its next *metadata* edit."""
|
||||||
|
nb = new_body if new_body.endswith("\n") else new_body + "\n"
|
||||||
|
if not is_meta:
|
||||||
|
return nb
|
||||||
|
if entry_mod.FRONTMATTER_RE.match(prior_contents):
|
||||||
|
e = entry_mod.parse(prior_contents)
|
||||||
|
e.body = nb
|
||||||
|
return entry_mod.serialize(e)
|
||||||
|
return nb
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Request bodies
|
# Request bodies
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1163,28 +1194,12 @@ def make_router(
|
|||||||
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:
|
||||||
"""For super-draft entries (and active-RFC pre-graduation reads
|
return _extract_body_pure(
|
||||||
per §9.8) the file on disk is the full frontmatter+body envelope;
|
rfc, file_contents, branch, is_meta=_is_meta_target(rfc, branch))
|
||||||
the editable body is entry.body. For active RFCs reading their
|
|
||||||
per-RFC repo the file is just RFC.md and the whole thing is body."""
|
|
||||||
if not _is_meta_target(rfc, branch):
|
|
||||||
return file_contents
|
|
||||||
try:
|
|
||||||
entry = entry_mod.parse(file_contents)
|
|
||||||
except Exception:
|
|
||||||
return file_contents
|
|
||||||
return entry.body
|
|
||||||
|
|
||||||
def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str:
|
def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str:
|
||||||
"""Inverse of _extract_body: re-wrap a new body into the entry
|
return _wrap_body_pure(
|
||||||
envelope, preserving the prior frontmatter exactly."""
|
rfc, prior_contents, new_body, branch, is_meta=_is_meta_target(rfc, branch))
|
||||||
if not _is_meta_target(rfc, branch):
|
|
||||||
return new_body
|
|
||||||
entry = entry_mod.parse(prior_contents)
|
|
||||||
# Ensure exactly one trailing newline so the serializer's
|
|
||||||
# round-trip is stable.
|
|
||||||
entry.body = new_body if new_body.endswith("\n") else new_body + "\n"
|
|
||||||
return entry_mod.serialize(entry)
|
|
||||||
|
|
||||||
async def _refresh_cache_for(rfc) -> None:
|
async def _refresh_cache_for(rfc) -> None:
|
||||||
if _is_meta_resident(rfc):
|
if _is_meta_resident(rfc):
|
||||||
|
|||||||
@@ -42,7 +42,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, db, entry as entry_mod, projects as projects_mod
|
from . import auth, cache, db, entry as entry_mod, metadata as metadata_mod, projects as projects_mod
|
||||||
from .bot import Actor, Bot
|
from .bot import Actor, Bot
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .gitea import Gitea, GiteaError
|
from .gitea import Gitea, GiteaError
|
||||||
@@ -341,19 +341,16 @@ def make_router(
|
|||||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||||
|
|
||||||
# Read the meta-repo entry once — we need the file's sha for the
|
# Dual-read the meta-repo entry once (§22.4a sidecar-aware) — we need its
|
||||||
# graduation PR's update_file call 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).
|
||||||
fetched = await gitea.read_file(
|
st = await metadata_mod.read_entry_from_git(
|
||||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
gitea, config.gitea_org,
|
||||||
|
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||||
)
|
)
|
||||||
if fetched 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 rfcs/{slug}.md not found on main")
|
||||||
meta_text, meta_sha = fetched
|
super_draft_entry = st.entry
|
||||||
try:
|
|
||||||
super_draft_entry = entry_mod.parse(meta_text)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
|
||||||
|
|
||||||
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
||||||
|
|
||||||
@@ -376,8 +373,12 @@ def make_router(
|
|||||||
models=super_draft_entry.models,
|
models=super_draft_entry.models,
|
||||||
funder=super_draft_entry.funder,
|
funder=super_draft_entry.funder,
|
||||||
body=super_draft_entry.body,
|
body=super_draft_entry.body,
|
||||||
|
# INV-7 (§22.4a): carry forward-compat / unknown frontmatter keys
|
||||||
|
# through graduation rather than dropping them on the rebuild.
|
||||||
|
extra=dict(super_draft_entry.extra),
|
||||||
)
|
)
|
||||||
graduated_contents = entry_mod.serialize(graduated_entry)
|
graduation_files = metadata_mod.write_entry_files(
|
||||||
|
f"rfcs/{slug}.md", 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,
|
||||||
@@ -397,8 +398,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,
|
||||||
graduated_contents=graduated_contents,
|
graduation_files=graduation_files,
|
||||||
meta_file_sha=meta_sha,
|
|
||||||
)
|
)
|
||||||
if request.query_params.get("_sync") == "1":
|
if request.query_params.get("_sync") == "1":
|
||||||
await coro
|
await coro
|
||||||
@@ -478,26 +478,23 @@ 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']}")
|
||||||
|
|
||||||
fetched = await gitea.read_file(
|
st = await metadata_mod.read_entry_from_git(
|
||||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
gitea, config.gitea_org,
|
||||||
|
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||||
)
|
)
|
||||||
if fetched 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 rfcs/{slug}.md not found on main")
|
||||||
meta_text, meta_sha = fetched
|
if viewer.gitea_login in st.entry.owners:
|
||||||
try:
|
|
||||||
ent = entry_mod.parse(meta_text)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
|
||||||
if viewer.gitea_login in ent.owners:
|
|
||||||
return {"ok": True, "noop": True}
|
return {"ok": True, "noop": True}
|
||||||
ent.owners = ent.owners + [viewer.gitea_login]
|
ent = metadata_mod.apply_values(
|
||||||
new_contents = entry_mod.serialize(ent)
|
st.entry, {"owners": st.entry.owners + [viewer.gitea_login]})
|
||||||
|
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", 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=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||||
slug=slug,
|
slug=slug,
|
||||||
new_file_contents=new_contents, prior_sha=meta_sha,
|
files=files,
|
||||||
)
|
)
|
||||||
except GiteaError as e:
|
except GiteaError as e:
|
||||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
@@ -524,11 +521,12 @@ 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"]
|
||||||
entry, sha = await _read_meta_entry(slug)
|
st = await _read_meta_entry(slug)
|
||||||
entry.state = "retired"
|
entry = metadata_mod.apply_values(st.entry, {"state": "retired"})
|
||||||
|
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", 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, new_contents=entry_mod.serialize(entry), prior_sha=sha,
|
slug=slug, files=files,
|
||||||
verb="retire", target_state="retired",
|
verb="retire", target_state="retired",
|
||||||
)
|
)
|
||||||
_audit(
|
_audit(
|
||||||
@@ -554,11 +552,12 @@ def make_router(
|
|||||||
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)
|
_require_retired(slug)
|
||||||
restored = _prior_state_before_retire(slug)
|
restored = _prior_state_before_retire(slug)
|
||||||
entry, sha = await _read_meta_entry(slug)
|
st = await _read_meta_entry(slug)
|
||||||
entry.state = restored
|
entry = metadata_mod.apply_values(st.entry, {"state": restored})
|
||||||
|
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", 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, new_contents=entry_mod.serialize(entry), prior_sha=sha,
|
slug=slug, files=files,
|
||||||
verb="unretire", target_state=restored,
|
verb="unretire", target_state=restored,
|
||||||
)
|
)
|
||||||
_audit(
|
_audit(
|
||||||
@@ -599,17 +598,17 @@ 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) -> tuple[entry_mod.Entry, str]:
|
async def _read_meta_entry(slug: str):
|
||||||
fetched = await gitea.read_file(
|
"""Dual-read an entry from meta-main → EntryGitState (sidecar-aware,
|
||||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
§22.4a). A migrated body-only `.md` reads cleanly; never raises on bad
|
||||||
|
metadata (INV-3)."""
|
||||||
|
st = await metadata_mod.read_entry_from_git(
|
||||||
|
gitea, config.gitea_org,
|
||||||
|
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||||
)
|
)
|
||||||
if fetched 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 rfcs/{slug}.md not found on main")
|
||||||
text, file_sha = fetched
|
return st
|
||||||
try:
|
|
||||||
return entry_mod.parse(text), file_sha
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
|
||||||
|
|
||||||
async def _refresh_catalog() -> None:
|
async def _refresh_catalog() -> None:
|
||||||
# Inline refresh so the catalog reflects the flip immediately; the
|
# Inline refresh so the catalog reflects the flip immediately; the
|
||||||
@@ -637,8 +636,7 @@ async def _orchestrate(
|
|||||||
bot: Bot,
|
bot: Bot,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
state: GraduationState,
|
state: GraduationState,
|
||||||
graduated_contents: str,
|
graduation_files: list[dict],
|
||||||
meta_file_sha: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Open the flip PR, then merge it. Two steps, no transaction:
|
"""Open the flip PR, then merge it. Two steps, no transaction:
|
||||||
|
|
||||||
@@ -658,8 +656,7 @@ async def _orchestrate(
|
|||||||
actor,
|
actor,
|
||||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||||
slug=state.slug,
|
slug=state.slug,
|
||||||
new_file_contents=graduated_contents,
|
files=graduation_files,
|
||||||
prior_sha=meta_file_sha,
|
|
||||||
rfc_id=state.rfc_id,
|
rfc_id=state.rfc_id,
|
||||||
owners=state.owners,
|
owners=state.owners,
|
||||||
)
|
)
|
||||||
@@ -847,12 +844,12 @@ async def _run_state_flip(
|
|||||||
bot: Bot,
|
bot: Bot,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
slug: str,
|
slug: str,
|
||||||
new_contents: str,
|
files: list[dict],
|
||||||
prior_sha: str,
|
|
||||||
verb: str,
|
verb: str,
|
||||||
target_state: str,
|
target_state: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""§13.7: open + merge a retire / un-retire frontmatter flip PR. Runs
|
"""§13.7: open + merge a retire / un-retire state-flip PR. The flip is
|
||||||
|
written to the entry's metadata sidecar (§22.4a) via `files` ops. Runs
|
||||||
inline (no SSE — the flip is a single quick state change, unlike the
|
inline (no SSE — the flip is a single quick state change, unlike the
|
||||||
multi-step graduation that streams progress). On an open failure
|
multi-step graduation that streams progress). On an open failure
|
||||||
nothing was created; on a merge failure the half-open PR/branch is
|
nothing was created; on a merge failure the half-open PR/branch is
|
||||||
@@ -862,7 +859,7 @@ async def _run_state_flip(
|
|||||||
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=(projects_mod.default_content_repo(config) or ""),
|
||||||
slug=slug, new_file_contents=new_contents, prior_sha=prior_sha,
|
slug=slug, files=files,
|
||||||
verb=verb, target_state=target_state,
|
verb=verb, target_state=target_state,
|
||||||
)
|
)
|
||||||
except GiteaError as e:
|
except GiteaError as e:
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""§22.4a SLICE-4/5 — entry metadata edit endpoints.
|
||||||
|
|
||||||
|
`POST .../rfcs/<slug>/meta` writes schema-defined metadata to an entry's sidecar
|
||||||
|
with a direct commit (D7: direct commit for authorized roles), validated against
|
||||||
|
the collection's field schema at the write boundary (INV-4), lazy-migrating a
|
||||||
|
legacy entry to a clean body-only `.md` on first edit. The Owner-gated
|
||||||
|
`metadata.migrate_collection` operator endpoint also lives here (SLICE-4 carried
|
||||||
|
work); SLICE-5's bulk endpoint will join it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from . import (auth, cache, collections as collections_mod,
|
||||||
|
metadata as metadata_mod, metadata_schema,
|
||||||
|
projects as projects_mod)
|
||||||
|
from .bot import Bot
|
||||||
|
from .config import Config
|
||||||
|
from .gitea import Gitea, GiteaError
|
||||||
|
|
||||||
|
|
||||||
|
class MetaEditBody(BaseModel):
|
||||||
|
values: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class BulkMetaBody(BaseModel):
|
||||||
|
slugs: list[str]
|
||||||
|
op: str
|
||||||
|
field: str
|
||||||
|
value: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_op(entry: Any, op: str, field: str, value: Any) -> Any:
|
||||||
|
"""Return the new value for `field` after applying `op` to `entry`.
|
||||||
|
|
||||||
|
`set` → `value`; `add`/`remove` operate on the entry's current tags-list
|
||||||
|
value for `field` (the route restricts add/remove to tags-type fields).
|
||||||
|
"""
|
||||||
|
if op == "set":
|
||||||
|
return value
|
||||||
|
current = metadata_mod.metadata_dict(entry).get(field) or []
|
||||||
|
if not isinstance(current, list):
|
||||||
|
current = [current]
|
||||||
|
if op == "add":
|
||||||
|
return current if value in current else [*current, value]
|
||||||
|
if op == "remove":
|
||||||
|
return [x for x in current if x != value]
|
||||||
|
return value # unreachable; op validated by the route
|
||||||
|
|
||||||
|
|
||||||
|
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def _content_repo() -> tuple[str, str]:
|
||||||
|
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
||||||
|
|
||||||
|
def _md_path(collection_id: str, slug: str) -> str:
|
||||||
|
sub = collections_mod.subfolder_of(collection_id) or ""
|
||||||
|
rfcs_dir = f"{sub}/rfcs" if sub else "rfcs"
|
||||||
|
return f"{rfcs_dir}/{slug}.md"
|
||||||
|
|
||||||
|
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/meta")
|
||||||
|
async def edit_meta(
|
||||||
|
project_id: str, collection_id: str, slug: str,
|
||||||
|
body: MetaEditBody, request: Request,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
viewer = auth.current_user(request)
|
||||||
|
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||||
|
raise HTTPException(404, "Collection not in project")
|
||||||
|
# INV-4: contributor+ on the collection (returns False for anonymous).
|
||||||
|
if not auth.can_contribute_in_collection(viewer, collection_id):
|
||||||
|
raise HTTPException(403, "Contributor access required to edit metadata")
|
||||||
|
col = collections_mod.get_collection(collection_id)
|
||||||
|
fields = (col or {}).get("fields") or {}
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(422, "Collection declares no editable fields")
|
||||||
|
if not body.values:
|
||||||
|
raise HTTPException(422, "Provide at least one field value")
|
||||||
|
unknown = [k for k in body.values if k not in fields]
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(unknown))}")
|
||||||
|
|
||||||
|
org, repo = _content_repo()
|
||||||
|
md_path = _md_path(collection_id, slug)
|
||||||
|
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||||
|
if st is None:
|
||||||
|
raise HTTPException(404, f"{md_path} not found")
|
||||||
|
|
||||||
|
# Validate the *raw* submitted values (INV-4): catch a type mismatch
|
||||||
|
# before `apply_values` coerces it — e.g. a scalar handed to a `tags`
|
||||||
|
# field would otherwise char-split into a valid-looking list.
|
||||||
|
problems = metadata_schema.validate(body.values, fields)
|
||||||
|
if problems:
|
||||||
|
raise HTTPException(422, {"problems": [p.as_dict() for p in problems]})
|
||||||
|
new_entry = metadata_mod.apply_values(st.entry, body.values)
|
||||||
|
|
||||||
|
files = metadata_mod.write_entry_files(md_path, new_entry, st)
|
||||||
|
try:
|
||||||
|
await bot.commit_entry_files(
|
||||||
|
viewer.as_actor(), org=org, repo=repo, files=files,
|
||||||
|
message=f"Edit metadata: {slug}", branch="main")
|
||||||
|
except GiteaError as e:
|
||||||
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
|
|
||||||
|
await cache.refresh_meta_repo(config, gitea)
|
||||||
|
return {
|
||||||
|
"ok": True, "slug": slug,
|
||||||
|
"meta": metadata_mod.metadata_dict(new_entry),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/api/projects/{project_id}/collections/{collection_id}/meta/bulk")
|
||||||
|
async def bulk_meta(
|
||||||
|
project_id: str, collection_id: str,
|
||||||
|
body: BulkMetaBody, request: Request,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""§22.4a PUC-2 (SLICE-5): apply one field op to many entries at once.
|
||||||
|
|
||||||
|
`set` works for any field; `add`/`remove` operate on a tags-type field.
|
||||||
|
Each passing entry's metadata is validated at the write boundary
|
||||||
|
(INV-4) and its sidecar staged; all stage into **one** commit (D7:
|
||||||
|
bulk = 1 commit, reusing the SLICE-4 sidecar write-through). Entries
|
||||||
|
that are missing or fail validation are reported in `rejected`; the
|
||||||
|
rest in `applied`. A no-op (value unchanged) is applied without writing.
|
||||||
|
"""
|
||||||
|
viewer = auth.current_user(request)
|
||||||
|
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||||
|
raise HTTPException(404, "Collection not in project")
|
||||||
|
# INV-4: contributor+ on the collection (returns False for anonymous).
|
||||||
|
if not auth.can_contribute_in_collection(viewer, collection_id):
|
||||||
|
raise HTTPException(403, "Contributor access required to edit metadata")
|
||||||
|
col = collections_mod.get_collection(collection_id)
|
||||||
|
fields = (col or {}).get("fields") or {}
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(422, "Collection declares no editable fields")
|
||||||
|
if not body.slugs:
|
||||||
|
raise HTTPException(422, "Provide at least one entry")
|
||||||
|
if body.op not in ("set", "add", "remove"):
|
||||||
|
raise HTTPException(422, f"Unknown op: {body.op}")
|
||||||
|
if body.field not in fields:
|
||||||
|
raise HTTPException(422, f"Unknown field: {body.field}")
|
||||||
|
if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
|
||||||
|
raise HTTPException(422, f"op {body.op} requires a tags field")
|
||||||
|
|
||||||
|
org, repo = _content_repo()
|
||||||
|
applied: list[str] = []
|
||||||
|
rejected: list[dict[str, str]] = []
|
||||||
|
all_ops: list[dict[str, Any]] = []
|
||||||
|
for slug in body.slugs:
|
||||||
|
md_path = _md_path(collection_id, slug)
|
||||||
|
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||||
|
if st is None:
|
||||||
|
rejected.append({"slug": slug, "reason": "not found"})
|
||||||
|
continue
|
||||||
|
new_value = _apply_op(st.entry, body.op, body.field, body.value)
|
||||||
|
# Validate the *raw* new value before coercion (see edit_meta) so a
|
||||||
|
# scalar `set` onto a tags field is rejected, not char-split.
|
||||||
|
problems = metadata_schema.validate({body.field: new_value}, fields)
|
||||||
|
if problems:
|
||||||
|
rejected.append({"slug": slug,
|
||||||
|
"reason": "; ".join(p.message for p in problems)})
|
||||||
|
continue
|
||||||
|
applied.append(slug)
|
||||||
|
new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
|
||||||
|
if metadata_mod.metadata_dict(new_entry) != metadata_mod.metadata_dict(st.entry):
|
||||||
|
all_ops.extend(metadata_mod.write_entry_files(md_path, new_entry, st))
|
||||||
|
|
||||||
|
committed = False
|
||||||
|
if all_ops:
|
||||||
|
n = len(applied)
|
||||||
|
msg = f"Bulk {body.op} {body.field}: {n} entr{'y' if n == 1 else 'ies'}"
|
||||||
|
try:
|
||||||
|
await bot.commit_entry_files(
|
||||||
|
viewer.as_actor(), org=org, repo=repo, files=all_ops,
|
||||||
|
message=msg, branch="main")
|
||||||
|
except GiteaError as e:
|
||||||
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
|
committed = True
|
||||||
|
await cache.refresh_meta_repo(config, gitea)
|
||||||
|
return {"ok": True, "applied": applied,
|
||||||
|
"rejected": rejected, "committed": committed}
|
||||||
|
|
||||||
|
@router.post("/api/projects/{project_id}/collections/{collection_id}/migrate")
|
||||||
|
async def migrate(
|
||||||
|
project_id: str, collection_id: str, request: Request
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""§22.4a PUC-5: migrate a collection's legacy-frontmatter entries to
|
||||||
|
clean body-only `.md` + sidecars, one commit per collection. Owner-gated
|
||||||
|
operator action. Safe to ship now that every entry write path is
|
||||||
|
sidecar-aware (SLICE-4 carried work). Idempotent."""
|
||||||
|
viewer = auth.current_user(request)
|
||||||
|
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||||
|
raise HTTPException(404, "Collection not in project")
|
||||||
|
if not auth.is_collection_superuser(viewer, collection_id):
|
||||||
|
raise HTTPException(403, "Owner access required to migrate a collection")
|
||||||
|
org, repo = _content_repo()
|
||||||
|
subfolder = collections_mod.subfolder_of(collection_id) or ""
|
||||||
|
try:
|
||||||
|
result = await metadata_mod.migrate_collection(
|
||||||
|
gitea, org=org, repo=repo, subfolder=subfolder,
|
||||||
|
actor=viewer.as_actor())
|
||||||
|
except GiteaError as e:
|
||||||
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
|
if result["committed"]:
|
||||||
|
await cache.refresh_meta_repo(config, gitea)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return router
|
||||||
+14
-9
@@ -23,7 +23,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod, rfc_links
|
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod, rfc_links
|
||||||
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
|
||||||
@@ -1009,20 +1009,25 @@ async def _replay_changes(
|
|||||||
|
|
||||||
|
|
||||||
def _extract_body_for_replay(is_super_draft: bool, content: str) -> str:
|
def _extract_body_for_replay(is_super_draft: bool, content: str) -> str:
|
||||||
|
# §22.4a SLICE-4: a meta-resident entry may be legacy (frontmatter+body) or
|
||||||
|
# migrated (body-only). strip_frontmatter handles both without raising.
|
||||||
if not is_super_draft:
|
if not is_super_draft:
|
||||||
return content
|
return content
|
||||||
try:
|
return metadata_mod.strip_frontmatter(content)
|
||||||
return entry_mod.parse(content).body
|
|
||||||
except Exception:
|
|
||||||
return content
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str:
|
def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str:
|
||||||
|
# §22.4a SLICE-4: identity for body-only (migrated) files — never re-grow
|
||||||
|
# frontmatter; preserve a legacy file's frontmatter until its next metadata
|
||||||
|
# edit migrates it.
|
||||||
|
nb = new_body if new_body.endswith("\n") else new_body + "\n"
|
||||||
if not is_super_draft:
|
if not is_super_draft:
|
||||||
return new_body
|
return nb
|
||||||
entry = entry_mod.parse(prior_content)
|
if entry_mod.FRONTMATTER_RE.match(prior_content):
|
||||||
entry.body = new_body if new_body.endswith("\n") else new_body + "\n"
|
entry = entry_mod.parse(prior_content)
|
||||||
return entry_mod.serialize(entry)
|
entry.body = nb
|
||||||
|
return entry_mod.serialize(entry)
|
||||||
|
return nb
|
||||||
|
|
||||||
|
|
||||||
def _resolution_branch_name(original_branch: str) -> str:
|
def _resolution_branch_name(original_branch: str) -> str:
|
||||||
|
|||||||
+86
-78
@@ -27,7 +27,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from . import db, entry as entry_mod, notify
|
from . import db, entry as entry_mod, metadata as metadata_mod, notify
|
||||||
from .gitea import Gitea, GiteaError
|
from .gitea import Gitea, GiteaError
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -404,6 +404,43 @@ class Bot:
|
|||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ----- Entry sidecar writes (§22.4a SLICE-4) -----
|
||||||
|
|
||||||
|
async def commit_entry_files(
|
||||||
|
self, actor: Actor, *, org: str, repo: str,
|
||||||
|
files: list[dict], message: str, branch: str = "main",
|
||||||
|
) -> dict:
|
||||||
|
"""Commit a set of entry file ops (sidecar + body-only `.md`, from
|
||||||
|
`metadata.write_entry_files`) in one commit. Used by the direct-commit
|
||||||
|
metadata paths and, on a branch, by `open_entry_pr`."""
|
||||||
|
return await self._gitea.change_files(
|
||||||
|
org, repo, files=files,
|
||||||
|
message=_stamp_single(message, actor), branch=branch,
|
||||||
|
author_name=actor.display_name,
|
||||||
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def open_entry_pr(
|
||||||
|
self, actor: Actor, *, org: str, repo: str, slug: str,
|
||||||
|
files: list[dict], pr_title: str, pr_description: str,
|
||||||
|
branch_prefix: str = "metadata",
|
||||||
|
) -> dict:
|
||||||
|
"""Create a branch, commit entry file ops there, and open a PR — the
|
||||||
|
sidecar-aware successor to `open_metadata_pr`'s single-file write."""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
branch = f"{branch_prefix}-{slug}-{secrets.token_hex(3)}"
|
||||||
|
await self._gitea.create_branch(org, repo, branch, from_branch="main")
|
||||||
|
await self.commit_entry_files(
|
||||||
|
actor, org=org, repo=repo, files=files,
|
||||||
|
message=pr_title, branch=branch)
|
||||||
|
_subject, pr_body = _stamp("", pr_description, actor)
|
||||||
|
pr = await self._gitea.create_pull(
|
||||||
|
org, repo, title=pr_title, body=pr_body, head=branch, base="main")
|
||||||
|
_log(actor, "open_entry_pr", rfc_slug=slug, branch_name=branch,
|
||||||
|
pr_number=pr["number"], details={"pr_title": pr_title})
|
||||||
|
return pr
|
||||||
|
|
||||||
# ----- Meta repo: metadata-pane PRs (§9.5) -----
|
# ----- Meta repo: metadata-pane PRs (§9.5) -----
|
||||||
|
|
||||||
async def open_metadata_pr(
|
async def open_metadata_pr(
|
||||||
@@ -819,35 +856,29 @@ class Bot:
|
|||||||
org: str,
|
org: str,
|
||||||
meta_repo: str,
|
meta_repo: str,
|
||||||
slug: str,
|
slug: str,
|
||||||
new_file_contents: str,
|
files: list[dict],
|
||||||
prior_sha: str,
|
|
||||||
rfc_id: str | None,
|
rfc_id: str | None,
|
||||||
owners: list[str],
|
owners: list[str],
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
||||||
entry's frontmatter to `state: active` with the graduation stamps
|
entry to `state: active` with the graduation stamps and — **optionally**
|
||||||
and — **optionally** — the integer `id`, **keeping the body
|
— the integer `id`, **keeping the body unchanged** (§1 meta-only
|
||||||
unchanged** (§1 meta-only topology; no repo is created and no body
|
topology; no repo is created and no body is stripped). The graduation
|
||||||
is stripped). When `rfc_id` is None the entry graduates without a
|
metadata is written to the entry's sidecar (§22.4a) via `files`; a legacy
|
||||||
number (id stays null, slug is canonical per §2.3, §13.2). Branch
|
`.md` is lazy-migrated to body-only in the same commit. When `rfc_id` is
|
||||||
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
None the entry graduates without a number (id stays null, slug is
|
||||||
the other meta-repo branches per the §19.2 path-routing candidate.
|
canonical per §2.3, §13.2). Branch name uses the `graduate-<slug>-<6hex>`
|
||||||
|
shape — dash-separated like the other meta-repo branches per the §19.2
|
||||||
|
path-routing candidate.
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
|
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
|
||||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
|
||||||
commit_subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
commit_subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||||
commit_message = _stamp_single(commit_subject, actor)
|
result = await self.commit_entry_files(
|
||||||
result = await self._gitea.update_file(
|
actor, org=org, repo=meta_repo, files=files,
|
||||||
org, meta_repo, f"rfcs/{slug}.md",
|
message=commit_subject, branch=branch)
|
||||||
content=new_file_contents,
|
|
||||||
sha=prior_sha,
|
|
||||||
message=commit_message,
|
|
||||||
branch=branch,
|
|
||||||
author_name=actor.display_name, author_email=ae,
|
|
||||||
)
|
|
||||||
commit_sha = (
|
commit_sha = (
|
||||||
result.get("commit", {}).get("sha")
|
result.get("commit", {}).get("sha")
|
||||||
or result.get("content", {}).get("sha")
|
or result.get("content", {}).get("sha")
|
||||||
@@ -936,35 +967,26 @@ class Bot:
|
|||||||
org: str,
|
org: str,
|
||||||
meta_repo: str,
|
meta_repo: str,
|
||||||
slug: str,
|
slug: str,
|
||||||
new_file_contents: str,
|
files: list[dict],
|
||||||
prior_sha: str,
|
|
||||||
verb: str,
|
verb: str,
|
||||||
target_state: str,
|
target_state: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
|
"""§13.7: open a PR flipping an entry to `state: <target_state>` — for
|
||||||
<target_state>` — for retire (`verb='retire'`, target `retired`)
|
retire (`verb='retire'`, target `retired`) or un-retire
|
||||||
or un-retire (`verb='unretire'`, target the restored prior state).
|
(`verb='unretire'`, target the restored prior state). The `state` change
|
||||||
Only the frontmatter `state` changes; the body and every other
|
is written to the entry's metadata sidecar (§22.4a), keeping the `.md`
|
||||||
field (including the integer `id`) are kept, so an un-retire
|
body and every other field, so an un-retire restores the entry exactly.
|
||||||
restores the entry exactly. Branch shape mirrors graduation's
|
`files` come from `metadata.write_entry_files`. Branch shape mirrors
|
||||||
`<verb>-<slug>-<6hex>`.
|
graduation's `<verb>-<slug>-<6hex>`.
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
|
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
|
||||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
|
||||||
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
||||||
commit_subject = f"{verb_title} {slug}"
|
result = await self.commit_entry_files(
|
||||||
commit_message = _stamp_single(commit_subject, actor)
|
actor, org=org, repo=meta_repo, files=files,
|
||||||
result = await self._gitea.update_file(
|
message=f"{verb_title} {slug}", branch=branch)
|
||||||
org, meta_repo, f"rfcs/{slug}.md",
|
|
||||||
content=new_file_contents,
|
|
||||||
sha=prior_sha,
|
|
||||||
message=commit_message,
|
|
||||||
branch=branch,
|
|
||||||
author_name=actor.display_name, author_email=ae,
|
|
||||||
)
|
|
||||||
commit_sha = (
|
commit_sha = (
|
||||||
result.get("commit", {}).get("sha")
|
result.get("commit", {}).get("sha")
|
||||||
or result.get("content", {}).get("sha")
|
or result.get("content", {}).get("sha")
|
||||||
@@ -1134,30 +1156,23 @@ class Bot:
|
|||||||
org: str,
|
org: str,
|
||||||
meta_repo: str,
|
meta_repo: str,
|
||||||
slug: str,
|
slug: str,
|
||||||
new_file_contents: str,
|
files: list[dict],
|
||||||
prior_sha: str,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
|
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
|
||||||
|
|
||||||
Touches only the frontmatter of `rfcs/<slug>.md`. Branch shape is
|
Writes the updated `owners:` to the entry's metadata sidecar (§22.4a)
|
||||||
`claim/<slug>` — single attempt per super-draft per actor (Gitea
|
via `files`; a legacy `.md` is lazy-migrated to body-only in the same
|
||||||
refuses duplicate branch creation, which is the right behavior:
|
commit. Branch shape is `claim/<slug>` — single attempt per super-draft
|
||||||
if the claim is still open, point the contributor at the existing
|
per actor (Gitea refuses duplicate branch creation, which is the right
|
||||||
PR rather than opening a second one).
|
behavior: if the claim is still open, point the contributor at the
|
||||||
|
existing PR rather than opening a second one).
|
||||||
"""
|
"""
|
||||||
branch = f"claim/{slug}"
|
branch = f"claim/{slug}"
|
||||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
|
||||||
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
|
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
|
||||||
commit_message = _stamp_single(commit_subject, actor)
|
result = await self.commit_entry_files(
|
||||||
result = await self._gitea.update_file(
|
actor, org=org, repo=meta_repo, files=files,
|
||||||
org, meta_repo, f"rfcs/{slug}.md",
|
message=commit_subject, branch=branch)
|
||||||
content=new_file_contents,
|
|
||||||
sha=prior_sha,
|
|
||||||
message=commit_message,
|
|
||||||
branch=branch,
|
|
||||||
author_name=actor.display_name, author_email=ae,
|
|
||||||
)
|
|
||||||
commit_sha = (
|
commit_sha = (
|
||||||
result.get("commit", {}).get("sha")
|
result.get("commit", {}).get("sha")
|
||||||
or result.get("content", {}).get("sha")
|
or result.get("content", {}).get("sha")
|
||||||
@@ -1196,29 +1211,22 @@ class Bot:
|
|||||||
reviewed_by: str,
|
reviewed_by: str,
|
||||||
reviewed_at: str,
|
reviewed_at: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Clear §22.4c unreviewed on an active entry by rewriting its
|
"""Clear §22.4c unreviewed on an active entry by writing its metadata
|
||||||
frontmatter on main. Stamps the commit with the §6.5 On-behalf-of
|
sidecar on main (§22.4a). Dual-reads the entry (so a migrated body-only
|
||||||
trailer and writes an actions-log row, mirroring the graduation
|
`.md` doesn't crash) and lazy-migrates a legacy `.md` to body-only in the
|
||||||
stamp's bot-write shape."""
|
same commit. Stamps the §6.5 On-behalf-of trailer and writes an
|
||||||
|
actions-log row, mirroring the graduation stamp's bot-write shape."""
|
||||||
path = f"rfcs/{slug}.md"
|
path = f"rfcs/{slug}.md"
|
||||||
result = await self._gitea.read_file(org, meta_repo, path, ref="main")
|
st = await metadata_mod.read_entry_from_git(self._gitea, org, meta_repo, path)
|
||||||
if result is None:
|
if st is None:
|
||||||
raise GiteaError(404, f"{path} not found")
|
raise GiteaError(404, f"{path} not found")
|
||||||
text, sha = result
|
e = metadata_mod.apply_values(st.entry, {
|
||||||
e = entry_mod.parse(text)
|
"unreviewed": False, "reviewed_at": reviewed_at, "reviewed_by": reviewed_by,
|
||||||
e.unreviewed = False
|
})
|
||||||
e.reviewed_at = reviewed_at
|
files = metadata_mod.write_entry_files(path, e, st)
|
||||||
e.reviewed_by = reviewed_by
|
result = await self.commit_entry_files(
|
||||||
commit_message = _stamp_single(f"Mark {slug} reviewed", actor)
|
actor, org=org, repo=meta_repo, files=files,
|
||||||
result = await self._gitea.update_file(
|
message=f"Mark {slug} reviewed", branch="main")
|
||||||
org, meta_repo, path,
|
|
||||||
content=entry_mod.serialize(e),
|
|
||||||
sha=sha,
|
|
||||||
message=commit_message,
|
|
||||||
branch="main",
|
|
||||||
author_name=actor.display_name,
|
|
||||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
||||||
)
|
|
||||||
commit_sha = (
|
commit_sha = (
|
||||||
result.get("commit", {}).get("sha")
|
result.get("commit", {}).get("sha")
|
||||||
or result.get("content", {}).get("sha")
|
or result.get("content", {}).get("sha")
|
||||||
|
|||||||
+65
-6
@@ -27,7 +27,15 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from . import db, entry as entry_mod, projects as projects_mod, registry as registry_mod
|
from . import (
|
||||||
|
collections as collections_mod,
|
||||||
|
db,
|
||||||
|
entry as entry_mod,
|
||||||
|
metadata as metadata_mod,
|
||||||
|
metadata_schema,
|
||||||
|
projects as projects_mod,
|
||||||
|
registry as registry_mod,
|
||||||
|
)
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .gitea import Gitea, GiteaError
|
from .gitea import Gitea, GiteaError
|
||||||
|
|
||||||
@@ -77,6 +85,22 @@ async def _refresh_collection_corpus(
|
|||||||
project_id, collection_id, rfcs_dir, e)
|
project_id, collection_id, rfcs_dir, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# §22.4a SLICE-2: a collection may declare a metadata field schema. Fetch it
|
||||||
|
# once for the whole corpus pass; entries whose stored values fail it are
|
||||||
|
# flagged malformed advisory-only (INV-3) — the read never hard-fails. A
|
||||||
|
# collection with no schema validates nothing (INV-5, the default unchanged).
|
||||||
|
col = collections_mod.get_collection(collection_id)
|
||||||
|
fields_schema = (col or {}).get("fields") or None
|
||||||
|
|
||||||
|
# §22.4a SLICE-1: an entry's metadata may live in a `<slug>.meta.yaml`
|
||||||
|
# sidecar (the source of truth) with the `.md` kept as pure prose. Map the
|
||||||
|
# sidecars surfaced by this listing so each `.md` can dual-read its sibling.
|
||||||
|
sidecar_path_by_slug = {
|
||||||
|
metadata_mod.slug_of_sidecar(f["name"]): f["path"]
|
||||||
|
for f in files
|
||||||
|
if f.get("type") == "file" and metadata_mod.is_sidecar(f.get("name", ""))
|
||||||
|
}
|
||||||
|
|
||||||
seen_slugs: set[str] = set()
|
seen_slugs: set[str] = set()
|
||||||
for f in files:
|
for f in files:
|
||||||
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
|
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
|
||||||
@@ -85,8 +109,14 @@ async def _refresh_collection_corpus(
|
|||||||
if not result:
|
if not result:
|
||||||
continue
|
continue
|
||||||
text, sha = result
|
text, sha = result
|
||||||
|
stem = f["name"][:-len(".md")]
|
||||||
|
sidecar_text: str | None = None
|
||||||
|
sidecar_path = sidecar_path_by_slug.get(stem)
|
||||||
|
if sidecar_path:
|
||||||
|
sc_result = await gitea.read_file(org, repo, sidecar_path, ref="main")
|
||||||
|
sidecar_text = sc_result[0] if sc_result else None
|
||||||
try:
|
try:
|
||||||
entry = entry_mod.parse(text)
|
entry, malformed = metadata_mod.read_entry(text, sidecar_text, fallback_slug=stem)
|
||||||
except Exception as parse_err:
|
except Exception as parse_err:
|
||||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
|
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
|
||||||
project_id, collection_id, f["path"], parse_err)
|
project_id, collection_id, f["path"], parse_err)
|
||||||
@@ -95,8 +125,24 @@ async def _refresh_collection_corpus(
|
|||||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug",
|
log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug",
|
||||||
project_id, collection_id, f["path"])
|
project_id, collection_id, f["path"])
|
||||||
continue
|
continue
|
||||||
|
if malformed:
|
||||||
|
log.warning("refresh_meta_repo: %s/%s: %s has malformed metadata sidecar",
|
||||||
|
project_id, collection_id, f["path"])
|
||||||
|
# §22.4a SLICE-2: advisory schema validation (INV-3). A schema violation
|
||||||
|
# flags the entry malformed without blocking the read, OR-ed onto any
|
||||||
|
# sidecar-syntax malformation above.
|
||||||
|
if fields_schema:
|
||||||
|
problems = metadata_schema.validate(
|
||||||
|
metadata_mod.metadata_dict(entry), fields_schema
|
||||||
|
)
|
||||||
|
if problems:
|
||||||
|
malformed = True
|
||||||
|
log.warning("refresh_meta_repo: %s/%s: %s fails its field schema: %s",
|
||||||
|
project_id, collection_id, f["path"],
|
||||||
|
"; ".join(p.message for p in problems))
|
||||||
seen_slugs.add(entry.slug)
|
seen_slugs.add(entry.slug)
|
||||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id,
|
||||||
|
metadata_malformed=malformed)
|
||||||
|
|
||||||
# Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries
|
# Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries
|
||||||
# as historical record (§3), so this fires only for out-of-band deletes;
|
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||||
@@ -112,13 +158,22 @@ async def _refresh_collection_corpus(
|
|||||||
project_id, collection_id, missing)
|
project_id, collection_id, missing)
|
||||||
|
|
||||||
|
|
||||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
|
def _upsert_cached_rfc(
|
||||||
|
entry: entry_mod.Entry,
|
||||||
|
body_sha: str,
|
||||||
|
collection_id: str = "default",
|
||||||
|
metadata_malformed: bool = False,
|
||||||
|
) -> None:
|
||||||
# §6.6: models_json stays NULL when the frontmatter key is absent
|
# §6.6: models_json stays NULL when the frontmatter key is absent
|
||||||
# (inherit operator universe) and '[]' for the explicit opt-out.
|
# (inherit operator universe) and '[]' for the explicit opt-out.
|
||||||
models_json = json.dumps(entry.models) if entry.models is not None else None
|
models_json = json.dumps(entry.models) if entry.models is not None else None
|
||||||
# §6.7: funder_login mirrors the optional `funder:` frontmatter
|
# §6.7: funder_login mirrors the optional `funder:` frontmatter
|
||||||
# field. NULL means absent — operator credentials are used.
|
# field. NULL means absent — operator credentials are used.
|
||||||
funder_login = entry.funder or None
|
funder_login = entry.funder or None
|
||||||
|
# §22.4a SLICE-3: persist the full per-entry metadata mapping (known keys +
|
||||||
|
# extra, never the body) so facet/filter can read any declared field. Stored
|
||||||
|
# via metadata_dict so the sidecar's forward-compat keys (INV-7) ride along.
|
||||||
|
meta_json = json.dumps(metadata_mod.metadata_dict(entry))
|
||||||
db.conn().execute(
|
db.conn().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO cached_rfcs
|
INSERT INTO cached_rfcs
|
||||||
@@ -126,8 +181,8 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str
|
|||||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||||
models_json, funder_login, body, body_sha,
|
models_json, funder_login, body, body_sha,
|
||||||
unreviewed, reviewed_at, reviewed_by, collection_id,
|
unreviewed, reviewed_at, reviewed_by, collection_id,
|
||||||
last_entry_commit_at, updated_at)
|
metadata_malformed, meta_json, last_entry_commit_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||||
ON CONFLICT(collection_id, slug) DO UPDATE SET
|
ON CONFLICT(collection_id, slug) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
state = excluded.state,
|
state = excluded.state,
|
||||||
@@ -147,6 +202,8 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str
|
|||||||
unreviewed = excluded.unreviewed,
|
unreviewed = excluded.unreviewed,
|
||||||
reviewed_at = excluded.reviewed_at,
|
reviewed_at = excluded.reviewed_at,
|
||||||
reviewed_by = excluded.reviewed_by,
|
reviewed_by = excluded.reviewed_by,
|
||||||
|
metadata_malformed = excluded.metadata_malformed,
|
||||||
|
meta_json = excluded.meta_json,
|
||||||
last_entry_commit_at = datetime('now'),
|
last_entry_commit_at = datetime('now'),
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
""",
|
""",
|
||||||
@@ -171,6 +228,8 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str
|
|||||||
entry.reviewed_at,
|
entry.reviewed_at,
|
||||||
entry.reviewed_by,
|
entry.reviewed_by,
|
||||||
collection_id,
|
collection_id,
|
||||||
|
1 if metadata_malformed else 0,
|
||||||
|
meta_json,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ def _enabled_models_from_config(config_json: str | None) -> list[str] | None:
|
|||||||
return [str(m) for m in em] if isinstance(em, list) else None
|
return [str(m) for m in em] if isinstance(em, list) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _fields_from_config(config_json: str | None) -> dict | None:
|
||||||
|
"""§22.4a SLICE-2 per-collection metadata field schema from a `config_json`
|
||||||
|
blob, or None when the collection declares no `fields:`. The stored value is
|
||||||
|
already normalized by `metadata_schema.parse_fields` at ingest, so it's
|
||||||
|
served verbatim."""
|
||||||
|
if not config_json:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cfg = json.loads(config_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
fields = cfg.get("fields") if isinstance(cfg, dict) else None
|
||||||
|
return fields if isinstance(fields, dict) and fields else None
|
||||||
|
|
||||||
|
|
||||||
def default_collection_id(project_id: str) -> str:
|
def default_collection_id(project_id: str) -> str:
|
||||||
"""The id of a project's default (S1: sole) collection. Falls back to the
|
"""The id of a project's default (S1: sole) collection. Falls back to the
|
||||||
literal 'default' when the project has no collection row yet."""
|
literal 'default' when the project has no collection row yet."""
|
||||||
@@ -102,7 +117,12 @@ def get_collection(collection_id: str) -> dict | None:
|
|||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
out = dict(row)
|
out = dict(row)
|
||||||
out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None))
|
config_json = out.pop("config_json", None)
|
||||||
|
out["enabled_models"] = _enabled_models_from_config(config_json)
|
||||||
|
# §22.4a SLICE-2: serve the collection's metadata field schema (None when
|
||||||
|
# the collection declares no `fields:` — INV-5, the default `document`
|
||||||
|
# collection is unaffected).
|
||||||
|
out["fields"] = _fields_from_config(config_json)
|
||||||
out["entry_noun"] = entry_noun(out["type"])
|
out["entry_noun"] = entry_noun(out["type"])
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
+44
-2
@@ -58,6 +58,20 @@ class Entry:
|
|||||||
reviewed_at: str | None = None
|
reviewed_at: str | None = None
|
||||||
reviewed_by: str | None = None
|
reviewed_by: str | None = None
|
||||||
body: str = ""
|
body: str = ""
|
||||||
|
# §22.4a (configurable collection metadata, SLICE-1): frontmatter / sidecar
|
||||||
|
# keys outside the known set above are preserved here verbatim so they ride
|
||||||
|
# along untouched through a parse→serialize round-trip and the
|
||||||
|
# frontmatter→sidecar migration (INV-7). Includes future collection-`fields:`
|
||||||
|
# schema values, which the engine does not interpret.
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# Frontmatter keys the Entry models explicitly; everything else is `extra`.
|
||||||
|
KNOWN_KEYS = {
|
||||||
|
"slug", "title", "state", "id", "repo", "proposed_by", "proposed_at",
|
||||||
|
"graduated_at", "graduated_by", "owners", "arbiters", "tags", "models",
|
||||||
|
"funder", "unreviewed", "reviewed_at", "reviewed_by",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse(text: str) -> Entry:
|
def parse(text: str) -> Entry:
|
||||||
@@ -66,6 +80,16 @@ def parse(text: str) -> Entry:
|
|||||||
raise ValueError("Entry file missing frontmatter")
|
raise ValueError("Entry file missing frontmatter")
|
||||||
fm = yaml.safe_load(match.group(1)) or {}
|
fm = yaml.safe_load(match.group(1)) or {}
|
||||||
body = match.group(2).lstrip("\n")
|
body = match.group(2).lstrip("\n")
|
||||||
|
return from_frontmatter(fm, body)
|
||||||
|
|
||||||
|
|
||||||
|
def from_frontmatter(fm: dict[str, Any], body: str = "") -> Entry:
|
||||||
|
"""Build an Entry from an already-parsed metadata mapping + body.
|
||||||
|
|
||||||
|
Shared by `parse()` (legacy `.md` frontmatter) and the SLICE-1 dual-read
|
||||||
|
sidecar path (`metadata.read_entry`), so both produce identical records
|
||||||
|
(INV-6). `fm` keys outside `KNOWN_KEYS` are preserved on `Entry.extra`.
|
||||||
|
"""
|
||||||
raw_models = fm.get("models", _ABSENT)
|
raw_models = fm.get("models", _ABSENT)
|
||||||
if raw_models is _ABSENT or raw_models is None:
|
if raw_models is _ABSENT or raw_models is None:
|
||||||
models: list[str] | None = None
|
models: list[str] | None = None
|
||||||
@@ -74,6 +98,7 @@ def parse(text: str) -> Entry:
|
|||||||
raw_funder = fm.get("funder")
|
raw_funder = fm.get("funder")
|
||||||
funder = str(raw_funder).strip() if raw_funder else None
|
funder = str(raw_funder).strip() if raw_funder else None
|
||||||
unreviewed = bool(fm.get("unreviewed") or False)
|
unreviewed = bool(fm.get("unreviewed") or False)
|
||||||
|
extra = {k: v for k, v in fm.items() if k not in KNOWN_KEYS}
|
||||||
return Entry(
|
return Entry(
|
||||||
slug=str(fm.get("slug") or ""),
|
slug=str(fm.get("slug") or ""),
|
||||||
title=str(fm.get("title") or ""),
|
title=str(fm.get("title") or ""),
|
||||||
@@ -93,11 +118,18 @@ def parse(text: str) -> Entry:
|
|||||||
reviewed_at=fm.get("reviewed_at") or None,
|
reviewed_at=fm.get("reviewed_at") or None,
|
||||||
reviewed_by=fm.get("reviewed_by") or None,
|
reviewed_by=fm.get("reviewed_by") or None,
|
||||||
body=body,
|
body=body,
|
||||||
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def serialize(entry: Entry) -> str:
|
def to_frontmatter_dict(entry: Entry) -> dict[str, Any]:
|
||||||
"""Emit canonical entry file text — frontmatter then body."""
|
"""The canonical ordered metadata mapping for an entry.
|
||||||
|
|
||||||
|
Shared by `serialize()` (which wraps it in `---` fences over the body) and
|
||||||
|
the SLICE-1 sidecar writer (`metadata.sidecar_yaml`, which emits the same
|
||||||
|
mapping as a standalone `<slug>.meta.yaml`). Known keys first in canonical
|
||||||
|
order, then `extra` (INV-7).
|
||||||
|
"""
|
||||||
fm: dict[str, Any] = {
|
fm: dict[str, Any] = {
|
||||||
"slug": entry.slug,
|
"slug": entry.slug,
|
||||||
"title": entry.title,
|
"title": entry.title,
|
||||||
@@ -129,6 +161,16 @@ def serialize(entry: Entry) -> str:
|
|||||||
fm["reviewed_at"] = entry.reviewed_at
|
fm["reviewed_at"] = entry.reviewed_at
|
||||||
if entry.reviewed_by:
|
if entry.reviewed_by:
|
||||||
fm["reviewed_by"] = entry.reviewed_by
|
fm["reviewed_by"] = entry.reviewed_by
|
||||||
|
# INV-7: forward-compat / unknown keys ride along after the known ones.
|
||||||
|
for k, v in entry.extra.items():
|
||||||
|
if k not in fm:
|
||||||
|
fm[k] = v
|
||||||
|
return fm
|
||||||
|
|
||||||
|
|
||||||
|
def serialize(entry: Entry) -> str:
|
||||||
|
"""Emit canonical entry file text — frontmatter then body."""
|
||||||
|
fm = to_frontmatter_dict(entry)
|
||||||
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
||||||
body = entry.body.lstrip("\n")
|
body = entry.body.lstrip("\n")
|
||||||
if body:
|
if body:
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""§22.4a SLICE-3 — faceted catalog filtering + counts (read).
|
||||||
|
|
||||||
|
Pure functions over already-mirrored entries; no I/O, no DB. An "entry" here is
|
||||||
|
a plain dict carrying at least:
|
||||||
|
- "state": the lifecycle state column,
|
||||||
|
- "metadata_malformed": bool,
|
||||||
|
- "meta": the per-entry metadata mapping (from cached_rfcs.meta_json).
|
||||||
|
|
||||||
|
Facetable fields (§5.1, plan decision 1): a collection's declared `enum` and
|
||||||
|
`tags` fields, in declaration order, plus the built-in `state` facet appended
|
||||||
|
last — but only when the collection declares a schema (INV-5: a no-`fields:`
|
||||||
|
collection has no facets at all, so the frontend keeps its legacy chips). `text`
|
||||||
|
fields are not faceted in v1 (they get a detail control in SLICE-4).
|
||||||
|
|
||||||
|
Counts use drill-down semantics (plan decision 2): the count for a value of
|
||||||
|
field F is taken over entries matching every OTHER field's selection (and the
|
||||||
|
malformed toggle), not F's own — so within-field values stay switchable (OR
|
||||||
|
within a field, AND across fields). The returned items list applies ALL
|
||||||
|
selections.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# enum + tags are facetable; text is rendered as a detail control (SLICE-4).
|
||||||
|
FACETABLE_TYPES = {"enum", "tags"}
|
||||||
|
|
||||||
|
|
||||||
|
def facet_fields(fields: dict[str, dict] | None) -> list[tuple[str, str]]:
|
||||||
|
"""Ordered `[(name, type), ...]` facetable from the schema, `state` last.
|
||||||
|
|
||||||
|
Empty when the collection declares no schema (INV-5)."""
|
||||||
|
if not fields:
|
||||||
|
return []
|
||||||
|
out = [
|
||||||
|
(name, spec.get("type"))
|
||||||
|
for name, spec in fields.items()
|
||||||
|
if spec.get("type") in FACETABLE_TYPES
|
||||||
|
]
|
||||||
|
out.append(("state", "enum"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_filter_keys(fields: dict[str, dict] | None) -> set[str]:
|
||||||
|
"""Query-param keys the collection-scoped list accepts (plan decision 6)."""
|
||||||
|
keys = {name for name, _ in facet_fields(fields)}
|
||||||
|
keys.update({"unreviewed", "malformed"})
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _values_for(entry: dict[str, Any], name: str, ftype: str) -> list[str]:
|
||||||
|
"""The facet value(s) an entry contributes for field `name` (str-cast)."""
|
||||||
|
if name == "state":
|
||||||
|
v = entry.get("state")
|
||||||
|
return [str(v)] if v else []
|
||||||
|
meta = entry.get("meta") or {}
|
||||||
|
v = meta.get(name)
|
||||||
|
if v is None:
|
||||||
|
return []
|
||||||
|
if ftype == "tags":
|
||||||
|
return [str(x) for x in v] if isinstance(v, list) else []
|
||||||
|
return [str(v)]
|
||||||
|
|
||||||
|
|
||||||
|
def _matches(entry: dict[str, Any], name: str, ftype: str, selected: set[str]) -> bool:
|
||||||
|
if not selected:
|
||||||
|
return True
|
||||||
|
return bool(set(_values_for(entry, name, ftype)) & selected) # OR within field
|
||||||
|
|
||||||
|
|
||||||
|
def filter_and_count(
|
||||||
|
entries: list[dict[str, Any]],
|
||||||
|
fields: dict[str, dict] | None,
|
||||||
|
selections: dict[str, set[str]],
|
||||||
|
only_malformed: bool = False,
|
||||||
|
) -> tuple[list[dict[str, Any]], dict[str, dict[str, int]]]:
|
||||||
|
"""Filter `entries` by `selections` and compute drill-down facet counts.
|
||||||
|
|
||||||
|
`selections` maps a facet field name → the set of selected values (OR within
|
||||||
|
the field; AND across fields). `only_malformed` narrows items and counts to
|
||||||
|
entries flagged malformed (INV-3). Returns `(items, facets)` where
|
||||||
|
`facets = {field: {value: count}}`. With no schema → `([all passing], {})`.
|
||||||
|
"""
|
||||||
|
facetable = facet_fields(fields)
|
||||||
|
|
||||||
|
def passes_malformed(e: dict[str, Any]) -> bool:
|
||||||
|
return (not only_malformed) or bool(e.get("metadata_malformed"))
|
||||||
|
|
||||||
|
items = [
|
||||||
|
e for e in entries
|
||||||
|
if passes_malformed(e)
|
||||||
|
and all(_matches(e, n, t, selections.get(n, set())) for n, t in facetable)
|
||||||
|
]
|
||||||
|
|
||||||
|
facets: dict[str, dict[str, int]] = {}
|
||||||
|
for name, ftype in facetable:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for e in entries:
|
||||||
|
if not passes_malformed(e):
|
||||||
|
continue
|
||||||
|
if not all(
|
||||||
|
_matches(e, on, ot, selections.get(on, set()))
|
||||||
|
for on, ot in facetable
|
||||||
|
if on != name
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
for val in _values_for(e, name, ftype):
|
||||||
|
counts[val] = counts.get(val, 0) + 1
|
||||||
|
facets[name] = counts
|
||||||
|
return items, facets
|
||||||
@@ -193,6 +193,40 @@ class Gitea:
|
|||||||
resp = await self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=body)
|
resp = await self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=body)
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
async def change_files(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
*,
|
||||||
|
files: list[dict[str, Any]],
|
||||||
|
message: str,
|
||||||
|
branch: str,
|
||||||
|
author_name: str | None = None,
|
||||||
|
author_email: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Create/update/delete several files in ONE commit (Gitea ChangeFiles).
|
||||||
|
|
||||||
|
Each `files` entry is `{"operation": "create"|"update"|"delete",
|
||||||
|
"path": str, "content": str (for create/update), "sha": str (required
|
||||||
|
for update/delete)}`. Plaintext `content` is base64-encoded here.
|
||||||
|
Backs the §22.4a frontmatter→sidecar migration's "one commit per
|
||||||
|
collection" (`metadata_migrate`).
|
||||||
|
"""
|
||||||
|
out_files: list[dict[str, Any]] = []
|
||||||
|
for f in files:
|
||||||
|
item: dict[str, Any] = {"operation": f["operation"], "path": f["path"]}
|
||||||
|
if "content" in f and f["content"] is not None:
|
||||||
|
item["content"] = base64.b64encode(f["content"].encode("utf-8")).decode("ascii")
|
||||||
|
if f.get("sha"):
|
||||||
|
item["sha"] = f["sha"]
|
||||||
|
out_files.append(item)
|
||||||
|
body: dict[str, Any] = {"message": message, "branch": branch, "files": out_files}
|
||||||
|
if author_name and author_email:
|
||||||
|
body["author"] = {"name": author_name, "email": author_email}
|
||||||
|
body["committer"] = {"name": author_name, "email": author_email}
|
||||||
|
resp = await self._request("POST", f"/repos/{owner}/{repo}/contents", json=body)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
# ----- Pull requests -----
|
# ----- Pull requests -----
|
||||||
|
|
||||||
async def list_pulls(self, owner: str, repo: str, state: str = "open") -> list[dict]:
|
async def list_pulls(self, owner: str, repo: str, state: str = "open") -> list[dict]:
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ class OtcVerifyBody(BaseModel):
|
|||||||
trust_device: bool = False
|
trust_device: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginBody(BaseModel):
|
||||||
|
# The single configured test identity to sign in as. Must equal
|
||||||
|
# E2E_TEST_AUTH_EMAIL (case-insensitive) or the request is refused —
|
||||||
|
# see `/auth/test/login`.
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
class PasscodeSetBody(BaseModel):
|
class PasscodeSetBody(BaseModel):
|
||||||
passcode: str = Field(min_length=1, max_length=64)
|
passcode: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
@@ -101,7 +108,26 @@ async def lifespan(app: FastAPI):
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
db.run_migrations(config)
|
db.run_migrations(config)
|
||||||
db.init(config)
|
db.init(config)
|
||||||
|
# v0.52.0: shout if the deployed-env E2E test-auth shortcut is live.
|
||||||
|
# It mints owner sessions for one configured identity (see
|
||||||
|
# `/auth/test/login`); it must only ever be on for a pre-prod (PPE)
|
||||||
|
# host. A loud startup line means an accidental prod enablement is
|
||||||
|
# visible in the logs rather than silent.
|
||||||
|
if os.environ.get("E2E_TEST_AUTH_SECRET", "").strip() and os.environ.get(
|
||||||
|
"E2E_TEST_AUTH_EMAIL", ""
|
||||||
|
).strip():
|
||||||
|
log.warning(
|
||||||
|
"E2E TEST-AUTH IS ENABLED: POST /auth/test/login will mint an owner "
|
||||||
|
"session for %s. This must NEVER be set on production.",
|
||||||
|
os.environ["E2E_TEST_AUTH_EMAIL"].strip(),
|
||||||
|
)
|
||||||
gitea = Gitea(config)
|
gitea = Gitea(config)
|
||||||
|
# §22 framework heal: reconcile a divergent default-project collection id
|
||||||
|
# (migration 029's ≥2-projects seed names it after the project, e.g. 'ohm',
|
||||||
|
# but the mirror expects 'default') BEFORE the mirror runs, so the mirror
|
||||||
|
# merges onto the canonical 'default' collection instead of duplicating it.
|
||||||
|
# Idempotent no-op on fresh / single-project / already-aligned deployments.
|
||||||
|
projects.reconcile_default_collection_id(config)
|
||||||
# §22.2: mirror the registry before anything reads projects/content_repo.
|
# §22.2: mirror the registry before anything reads projects/content_repo.
|
||||||
# First boot has no last-good rows, so a missing/invalid registry is fatal
|
# First boot has no last-good rows, so a missing/invalid registry is fatal
|
||||||
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
||||||
@@ -380,6 +406,61 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
"needs_profile": needs_profile,
|
"needs_profile": needs_profile,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.52.0: deployed-environment E2E test-auth shortcut.
|
||||||
|
#
|
||||||
|
# Running the Playwright E2E suite against a *deployed* environment
|
||||||
|
# (PPE) is the §9 pre-prod gate. But the deployed env has neither of
|
||||||
|
# the two scaffolds the Tier-1 docker stack relies on for auth: a
|
||||||
|
# Mailpit sink to read the OTC code from, and direct SQLite access to
|
||||||
|
# inject a granted-owner row. This endpoint replaces both with a
|
||||||
|
# single gated gesture: it mints an authenticated OWNER session for
|
||||||
|
# one pre-configured throwaway identity.
|
||||||
|
#
|
||||||
|
# It is FAIL-CLOSED and must never function in production:
|
||||||
|
# * 404 unless BOTH `E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`
|
||||||
|
# are set — a prod deployment that sets neither cannot be coaxed
|
||||||
|
# into minting a session, and the route is invisible.
|
||||||
|
# * The caller must present the shared secret in `X-Test-Auth-Secret`
|
||||||
|
# (constant-time compare); a wrong/absent secret 404s (the route
|
||||||
|
# does not advertise itself to an unauthenticated caller).
|
||||||
|
# * Only the one configured email may be minted; any other address
|
||||||
|
# is refused (403). So an enabled PPE exposes exactly one
|
||||||
|
# throwaway owner identity, with the secret as the trust boundary.
|
||||||
|
#
|
||||||
|
# The hard secrets rule (§6.3) holds: the secret is a Secret Manager
|
||||||
|
# ref injected as env on the VM (never a literal in the repo), and the
|
||||||
|
# E2E runner presents it from SM at runtime (never echoed).
|
||||||
|
@router.post("/auth/test/login")
|
||||||
|
async def test_login(body: TestLoginBody, request: Request):
|
||||||
|
secret = os.environ.get("E2E_TEST_AUTH_SECRET", "").strip()
|
||||||
|
configured_email = os.environ.get("E2E_TEST_AUTH_EMAIL", "").strip()
|
||||||
|
# Feature off (the default, incl. production): route is invisible.
|
||||||
|
if not secret or not configured_email:
|
||||||
|
raise HTTPException(404, "Not Found")
|
||||||
|
presented = request.headers.get("x-test-auth-secret", "")
|
||||||
|
if not secrets.compare_digest(presented, secret):
|
||||||
|
# Don't reveal that the route exists to a caller without the
|
||||||
|
# secret — mirror the "off" shape exactly.
|
||||||
|
raise HTTPException(404, "Not Found")
|
||||||
|
if body.email.strip().lower() != configured_email.lower():
|
||||||
|
raise HTTPException(403, "email not permitted")
|
||||||
|
|
||||||
|
# Provision-or-link the row, then force it to a granted owner so
|
||||||
|
# the metadata write paths (SLICE-4/5) accept it — the deployed
|
||||||
|
# equivalent of the Tier-1 docker-compose backend-seed owner row.
|
||||||
|
user = otc.provision_or_link_user(body.email)
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET role = 'owner', permission_state = 'granted', "
|
||||||
|
"last_seen_at = datetime('now') WHERE id = ?",
|
||||||
|
(user.user_id,),
|
||||||
|
)
|
||||||
|
db.conn().commit()
|
||||||
|
user.role = "owner"
|
||||||
|
user.permission_state = "granted"
|
||||||
|
auth.store_session(request, user)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
|
# v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""§22.4a configurable collection metadata — sidecar storage + dual-read.
|
||||||
|
|
||||||
|
SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md.
|
||||||
|
|
||||||
|
Entry metadata is collection-configured and stored in a per-entry sidecar,
|
||||||
|
`<slug>.meta.yaml`, with the `.md` body kept as pure prose (INV-2). This module
|
||||||
|
is the storage/compat layer:
|
||||||
|
|
||||||
|
- the **dual-read** parser (`read_entry`) — read the sidecar if present, else
|
||||||
|
legacy top-of-document frontmatter, with identical resulting records
|
||||||
|
(INV-6);
|
||||||
|
- sidecar (de)serialization that preserves unknown / forward-compat keys
|
||||||
|
(INV-7), reusing `entry`'s canonical field semantics;
|
||||||
|
- lenient parsing that never hard-fails a read — a malformed sidecar surfaces
|
||||||
|
a flag, not an exception (INV-3).
|
||||||
|
|
||||||
|
The collection `fields:` schema and per-field validation are SLICE-2; faceted
|
||||||
|
filtering and the edit UIs are later slices. This module interprets no field
|
||||||
|
values — it only moves metadata between git and in-memory `Entry` records.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from . import entry as entry_mod
|
||||||
|
from .entry import Entry
|
||||||
|
|
||||||
|
SIDECAR_SUFFIX = ".meta.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
# ----- filename helpers -----
|
||||||
|
|
||||||
|
def sidecar_name(slug: str) -> str:
|
||||||
|
"""The sidecar filename for an entry whose markdown is `<slug>.md`."""
|
||||||
|
return f"{slug}{SIDECAR_SUFFIX}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_sidecar(name: str) -> bool:
|
||||||
|
return name.endswith(SIDECAR_SUFFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def slug_of_sidecar(name: str) -> str:
|
||||||
|
"""The entry stem for a `<slug>.meta.yaml` filename."""
|
||||||
|
return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name
|
||||||
|
|
||||||
|
|
||||||
|
def sidecar_path_for(md_path: str) -> str:
|
||||||
|
"""The sidecar path sibling to a `<dir>/<slug>.md` entry file."""
|
||||||
|
assert md_path.endswith(".md"), md_path
|
||||||
|
return md_path[: -len(".md")] + SIDECAR_SUFFIX
|
||||||
|
|
||||||
|
|
||||||
|
# ----- metadata <-> sidecar -----
|
||||||
|
|
||||||
|
def metadata_dict(entry: Entry) -> dict[str, Any]:
|
||||||
|
"""The full metadata mapping for an entry (known fields + `extra`)."""
|
||||||
|
return entry_mod.to_frontmatter_dict(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def sidecar_yaml(entry: Entry) -> str:
|
||||||
|
"""Render an entry's metadata as standalone `<slug>.meta.yaml` text."""
|
||||||
|
return yaml.safe_dump(
|
||||||
|
metadata_dict(entry), sort_keys=False, default_flow_style=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sidecar(text: str) -> tuple[dict[str, Any], bool]:
|
||||||
|
"""Parse sidecar YAML leniently → `(values, malformed)`.
|
||||||
|
|
||||||
|
`malformed` is True when the text is not a YAML mapping (a list, a scalar,
|
||||||
|
or a YAML syntax error). An empty / whitespace-only sidecar is an empty
|
||||||
|
mapping, not malformed. Never raises (INV-3).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = yaml.safe_load(text)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return {}, True
|
||||||
|
if raw is None:
|
||||||
|
return {}, False
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}, True
|
||||||
|
return raw, False
|
||||||
|
|
||||||
|
|
||||||
|
# ----- frontmatter stripping (INV-2) -----
|
||||||
|
|
||||||
|
def strip_frontmatter(md_text: str) -> str:
|
||||||
|
"""Return the prose body of a `.md`, dropping a leading `---…---` block.
|
||||||
|
|
||||||
|
A migrated entry's `.md` is body-only and passes through unchanged. A
|
||||||
|
not-yet-migrated `.md` still carrying frontmatter yields just its body, so
|
||||||
|
the dual-read body is the same either way.
|
||||||
|
"""
|
||||||
|
match = entry_mod.FRONTMATTER_RE.match(md_text)
|
||||||
|
if not match:
|
||||||
|
return md_text
|
||||||
|
return match.group(2).lstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ----- dual-read (INV-6) -----
|
||||||
|
|
||||||
|
def read_entry(
|
||||||
|
md_text: str, sidecar_text: str | None, *, fallback_slug: str | None = None
|
||||||
|
) -> tuple[Entry, bool]:
|
||||||
|
"""Read an entry from its `.md` and optional sidecar → `(Entry, malformed)`.
|
||||||
|
|
||||||
|
- **Sidecar present and well-formed (non-empty):** metadata comes from the
|
||||||
|
sidecar; the body is the `.md` stripped of any leading frontmatter. The
|
||||||
|
sidecar is the source of truth (INV-1) and wins over stale `.md`
|
||||||
|
frontmatter.
|
||||||
|
- **Sidecar present but malformed:** the entry still loads from the legacy
|
||||||
|
`.md` frontmatter (if any) and is flagged `malformed` (INV-3).
|
||||||
|
- **Sidecar present but empty:** it has nothing to override with, so fall
|
||||||
|
back to the `.md` frontmatter (not flagged).
|
||||||
|
- **No sidecar:** the legacy path — parse the `.md` frontmatter (INV-6).
|
||||||
|
|
||||||
|
`fallback_slug` (typically the filename stem) backstops the entry's slug
|
||||||
|
whenever the metadata source lacks one — so a degenerate sidecar never
|
||||||
|
yields a slug-less record the caller has to silently drop (INV-3).
|
||||||
|
"""
|
||||||
|
def _with_slug(entry: Entry) -> Entry:
|
||||||
|
if not entry.slug and fallback_slug:
|
||||||
|
entry.slug = fallback_slug
|
||||||
|
return entry
|
||||||
|
|
||||||
|
if sidecar_text is None:
|
||||||
|
return _with_slug(entry_mod.parse(md_text)), False
|
||||||
|
|
||||||
|
values, malformed = parse_sidecar(sidecar_text)
|
||||||
|
if malformed or not values:
|
||||||
|
# Malformed or empty sidecar: load from the legacy .md so the entry
|
||||||
|
# still loads; flag only when the sidecar was actually malformed.
|
||||||
|
try:
|
||||||
|
entry = entry_mod.parse(md_text)
|
||||||
|
except ValueError:
|
||||||
|
entry = entry_mod.from_frontmatter({}, strip_frontmatter(md_text))
|
||||||
|
return _with_slug(entry), malformed
|
||||||
|
|
||||||
|
body = strip_frontmatter(md_text)
|
||||||
|
return _with_slug(entry_mod.from_frontmatter(values, body)), False
|
||||||
|
|
||||||
|
|
||||||
|
# ----- value editing (SLICE-4) -----
|
||||||
|
|
||||||
|
def apply_values(entry: Entry, values: dict[str, Any]) -> Entry:
|
||||||
|
"""Return a new Entry with `values` merged over the entry's metadata.
|
||||||
|
|
||||||
|
Known keys (`tags`, `state`, `reviewed_by`, …) land on their typed fields;
|
||||||
|
unknown keys land on `extra` (INV-7). The body is carried through unchanged
|
||||||
|
— this mutates metadata only. Unspecified keys are preserved.
|
||||||
|
"""
|
||||||
|
merged = metadata_dict(entry)
|
||||||
|
merged.update(values)
|
||||||
|
return entry_mod.from_frontmatter(merged, entry.body)
|
||||||
|
|
||||||
|
|
||||||
|
# ----- git-aware read/write (SLICE-4) -----
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EntryGitState:
|
||||||
|
"""An entry's on-disk state across its `.md` and optional sidecar.
|
||||||
|
|
||||||
|
Captured by `read_entry_from_git` and consumed by `write_entry_files` to
|
||||||
|
decide create-vs-update for the sidecar and whether the `.md` still needs
|
||||||
|
its frontmatter stripped (lazy migration).
|
||||||
|
"""
|
||||||
|
entry: Entry
|
||||||
|
md_text: str
|
||||||
|
md_sha: str
|
||||||
|
sidecar_text: str | None
|
||||||
|
sidecar_sha: str | None
|
||||||
|
malformed: bool
|
||||||
|
|
||||||
|
|
||||||
|
async def read_entry_from_git(
|
||||||
|
gitea: Any, org: str, repo: str, md_path: str, *, ref: str = "main"
|
||||||
|
) -> "EntryGitState | None":
|
||||||
|
"""Dual-read an entry from git → `EntryGitState`, or None if the `.md` is
|
||||||
|
missing. Reads the `.md` and its sibling sidecar (if any); never raises on
|
||||||
|
bad metadata (INV-3)."""
|
||||||
|
md = await gitea.read_file(org, repo, md_path, ref=ref)
|
||||||
|
if md is None:
|
||||||
|
return None
|
||||||
|
md_text, md_sha = md
|
||||||
|
sc_path = sidecar_path_for(md_path)
|
||||||
|
sc = await gitea.read_file(org, repo, sc_path, ref=ref)
|
||||||
|
sidecar_text, sidecar_sha = (sc[0], sc[1]) if sc else (None, None)
|
||||||
|
stem = md_path.rsplit("/", 1)[-1][: -len(".md")]
|
||||||
|
entry, malformed = read_entry(md_text, sidecar_text, fallback_slug=stem)
|
||||||
|
return EntryGitState(
|
||||||
|
entry=entry, md_text=md_text, md_sha=md_sha,
|
||||||
|
sidecar_text=sidecar_text, sidecar_sha=sidecar_sha, malformed=malformed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _md_has_frontmatter(md_text: str) -> bool:
|
||||||
|
return entry_mod.FRONTMATTER_RE.match(md_text) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def write_entry_files(
|
||||||
|
md_path: str, entry: Entry, state: "EntryGitState"
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Produce `change_files` ops that persist `entry`'s metadata to its sidecar
|
||||||
|
and keep the `.md` as pure prose (INV-1/INV-2).
|
||||||
|
|
||||||
|
- Sidecar: `create` when none existed, else `update` at its prior sha.
|
||||||
|
- `.md`: rewritten body-only **only when it still carries frontmatter**
|
||||||
|
(lazy migration, INV-6); an already-clean body is left untouched.
|
||||||
|
"""
|
||||||
|
sc_path = sidecar_path_for(md_path)
|
||||||
|
ops: list[dict[str, Any]] = []
|
||||||
|
sc_op: dict[str, Any] = {
|
||||||
|
"operation": "update" if state.sidecar_sha else "create",
|
||||||
|
"path": sc_path,
|
||||||
|
"content": sidecar_yaml(entry),
|
||||||
|
}
|
||||||
|
if state.sidecar_sha:
|
||||||
|
sc_op["sha"] = state.sidecar_sha
|
||||||
|
ops.append(sc_op)
|
||||||
|
if _md_has_frontmatter(state.md_text):
|
||||||
|
body = strip_frontmatter(state.md_text)
|
||||||
|
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
|
||||||
|
ops.append({
|
||||||
|
"operation": "update", "path": md_path,
|
||||||
|
"content": new_md, "sha": state.md_sha,
|
||||||
|
})
|
||||||
|
return ops
|
||||||
|
|
||||||
|
|
||||||
|
# ----- frontmatter -> sidecar migration (PUC-5) -----
|
||||||
|
|
||||||
|
async def migrate_collection(
|
||||||
|
gitea: Any,
|
||||||
|
*,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
subfolder: str = "",
|
||||||
|
actor: Any = None,
|
||||||
|
branch: str = "main",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Migrate a collection's legacy-frontmatter entries to sidecars.
|
||||||
|
|
||||||
|
Walks `<subfolder>/rfcs`; for each `<slug>.md` that has legacy frontmatter
|
||||||
|
and **no** `<slug>.meta.yaml` sibling yet, it stages two file changes —
|
||||||
|
create the sidecar (the entry's metadata, unknown keys preserved, INV-7)
|
||||||
|
and rewrite the `.md` to body-only (INV-2) — and commits all of them in a
|
||||||
|
single ChangeFiles commit (§6.5: one commit per collection).
|
||||||
|
|
||||||
|
Idempotent: an entry that already has a sidecar is skipped; a second run
|
||||||
|
with nothing left to migrate makes no commit. Returns
|
||||||
|
`{"migrated": [...], "skipped": [...], "committed": bool}`.
|
||||||
|
"""
|
||||||
|
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||||
|
listing = await gitea.list_dir(org, repo, rfcs_dir, ref=branch)
|
||||||
|
names = {f.get("name") for f in listing if f.get("type") == "file"}
|
||||||
|
|
||||||
|
ops: list[dict[str, Any]] = []
|
||||||
|
migrated: list[str] = []
|
||||||
|
skipped: list[str] = []
|
||||||
|
for f in listing:
|
||||||
|
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
|
||||||
|
continue
|
||||||
|
slug = f["name"][:-len(".md")]
|
||||||
|
if sidecar_name(slug) in names:
|
||||||
|
skipped.append(slug) # already migrated
|
||||||
|
continue
|
||||||
|
result = await gitea.read_file(org, repo, f["path"], ref=branch)
|
||||||
|
if not result:
|
||||||
|
continue
|
||||||
|
text, sha = result
|
||||||
|
try:
|
||||||
|
e = entry_mod.parse(text)
|
||||||
|
except ValueError:
|
||||||
|
# No frontmatter to lift (e.g. an already-clean body without a
|
||||||
|
# sidecar) — nothing to migrate; leave it untouched.
|
||||||
|
skipped.append(slug)
|
||||||
|
continue
|
||||||
|
body = strip_frontmatter(text)
|
||||||
|
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
|
||||||
|
ops.append({
|
||||||
|
"operation": "create",
|
||||||
|
"path": f"{rfcs_dir}/{sidecar_name(slug)}",
|
||||||
|
"content": sidecar_yaml(e),
|
||||||
|
})
|
||||||
|
ops.append({
|
||||||
|
"operation": "update",
|
||||||
|
"path": f["path"],
|
||||||
|
"content": new_md,
|
||||||
|
"sha": sha,
|
||||||
|
})
|
||||||
|
migrated.append(slug)
|
||||||
|
|
||||||
|
committed = False
|
||||||
|
if ops:
|
||||||
|
n = len(migrated)
|
||||||
|
message = f"Migrate {n} entr{'y' if n == 1 else 'ies'} to metadata sidecars (§22.4a)"
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
if actor is not None:
|
||||||
|
kwargs = {
|
||||||
|
"author_name": actor.display_name,
|
||||||
|
"author_email": actor.email or f"{actor.gitea_login}@users.noreply",
|
||||||
|
}
|
||||||
|
await gitea.change_files(
|
||||||
|
org, repo, files=ops, message=message, branch=branch, **kwargs
|
||||||
|
)
|
||||||
|
committed = True
|
||||||
|
|
||||||
|
return {"migrated": migrated, "skipped": skipped, "committed": committed}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""§22.4a configurable collection metadata — field schema + central validation.
|
||||||
|
|
||||||
|
SLICE-2 of docs/design/2026-06-06-configurable-collection-metadata.md.
|
||||||
|
|
||||||
|
A collection declares a small **field schema** in its `.collection.yaml`
|
||||||
|
(`fields:` block) so its entries can carry structured metadata — priority, tags,
|
||||||
|
and any custom fields the deployment defines. This module is the **one place**
|
||||||
|
that knows a collection's field shapes (modeled on `registry.py`):
|
||||||
|
|
||||||
|
- `parse_fields` — normalize + validate the declared schema, leniently: a bad
|
||||||
|
block or a bad field def is skipped with a warning, never raised, so a typo
|
||||||
|
in one field can't nuke the collection mirror (INV-3 spirit). The normalized
|
||||||
|
schema is a plain, JSON-serializable mapping that rides in
|
||||||
|
`collections.config_json` (no DB migration) and is served verbatim by the
|
||||||
|
collection API.
|
||||||
|
- `validate` — check an entry's stored values against the schema, returning a
|
||||||
|
list of advisory `Problem`s. Empty list = clean. Used **advisory at read**
|
||||||
|
(the corpus mirror flags a non-empty result as `metadata_malformed`, INV-3)
|
||||||
|
and is the enforcement point at the **write** boundary (the metadata-edit
|
||||||
|
endpoints land in SLICE-4/5).
|
||||||
|
|
||||||
|
Field types (v1): `enum` (single scalar, controlled by a required `values:`
|
||||||
|
list), `tags` (a list; free-form unless `values:` given), `text` (a free
|
||||||
|
string). `ref` / `multi-enum` are future (design §2, Q2). Unknown types are
|
||||||
|
ignored with a warning. Keys an entry carries that the schema does **not**
|
||||||
|
declare ride along untouched and are never flagged (INV-7).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# §6.3 v1 field types. `ref` (typed cross-entry link) and `multi-enum` are
|
||||||
|
# deferred (design §2, Q2) — declared with an unknown type they're skipped.
|
||||||
|
VALID_FIELD_TYPES = {"enum", "tags", "text"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Problem:
|
||||||
|
"""One advisory schema-validation problem against a declared field.
|
||||||
|
|
||||||
|
`code` is a stable machine token (`not-in-values`, `wrong-type`); `message`
|
||||||
|
is human-facing (surfaced at the write boundary in SLICE-4/5)."""
|
||||||
|
field: str
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return {"field": self.field, "code": self.code, "message": self.message}
|
||||||
|
|
||||||
|
|
||||||
|
# ----- schema parsing (lenient) -----
|
||||||
|
|
||||||
|
def parse_fields(raw: Any) -> dict[str, dict]:
|
||||||
|
"""Normalize a `.collection.yaml` `fields:` block → `{name: {type, ...}}`.
|
||||||
|
|
||||||
|
Pure (no I/O). Lenient (INV-3): a non-mapping block yields `{}`; an
|
||||||
|
individual field def that is not a mapping, has an unknown/missing `type`, or
|
||||||
|
is an `enum` without a non-empty `values:` list is **skipped with a warning**
|
||||||
|
— never raised. Order is preserved (facet display order, SLICE-3). The
|
||||||
|
result is plain dicts so it serializes straight into `config_json` and the
|
||||||
|
collection API.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
if raw is not None:
|
||||||
|
log.warning("metadata_schema: fields block is not a mapping (%s); ignoring",
|
||||||
|
type(raw).__name__)
|
||||||
|
return {}
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for name, spec in raw.items():
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
log.warning("metadata_schema: field %r def is not a mapping; skipping", name)
|
||||||
|
continue
|
||||||
|
ftype = str(spec.get("type") or "").strip()
|
||||||
|
if ftype not in VALID_FIELD_TYPES:
|
||||||
|
log.warning("metadata_schema: field %r has unknown type %r; skipping",
|
||||||
|
name, ftype)
|
||||||
|
continue
|
||||||
|
values = spec.get("values")
|
||||||
|
norm_values: list[str] | None = None
|
||||||
|
if values is not None:
|
||||||
|
if not isinstance(values, list):
|
||||||
|
log.warning("metadata_schema: field %r values is not a list; ignoring",
|
||||||
|
name)
|
||||||
|
else:
|
||||||
|
norm_values = [str(v) for v in values]
|
||||||
|
if ftype == "enum" and not norm_values:
|
||||||
|
log.warning("metadata_schema: enum field %r needs a non-empty values "
|
||||||
|
"list; skipping", name)
|
||||||
|
continue
|
||||||
|
field_def: dict[str, Any] = {"type": ftype}
|
||||||
|
if norm_values is not None:
|
||||||
|
field_def["values"] = norm_values
|
||||||
|
label = spec.get("label")
|
||||||
|
if label:
|
||||||
|
field_def["label"] = str(label)
|
||||||
|
out[str(name)] = field_def
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ----- value validation (advisory) -----
|
||||||
|
|
||||||
|
def _is_scalar(v: Any) -> bool:
|
||||||
|
return isinstance(v, (str, int, float, bool))
|
||||||
|
|
||||||
|
|
||||||
|
def validate(values: dict[str, Any], fields: dict[str, dict]) -> list[Problem]:
|
||||||
|
"""Check an entry's metadata `values` against a collection's field schema.
|
||||||
|
|
||||||
|
Returns advisory `Problem`s (empty = clean). Only **declared** fields are
|
||||||
|
checked; a field the entry omits is fine (no required fields in v1), and a
|
||||||
|
key the schema doesn't declare rides along untouched (INV-7). With an empty
|
||||||
|
schema, everything is clean (INV-5). Never raises (INV-3).
|
||||||
|
"""
|
||||||
|
problems: list[Problem] = []
|
||||||
|
for name, spec in fields.items():
|
||||||
|
if name not in values:
|
||||||
|
continue
|
||||||
|
value = values[name]
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
ftype = spec.get("type")
|
||||||
|
allowed = spec.get("values")
|
||||||
|
if ftype == "enum":
|
||||||
|
if not _is_scalar(value):
|
||||||
|
problems.append(Problem(name, "wrong-type",
|
||||||
|
f"{name!r} must be a single value, got {type(value).__name__}"))
|
||||||
|
elif allowed is not None and str(value) not in allowed:
|
||||||
|
problems.append(Problem(name, "not-in-values",
|
||||||
|
f"{name!r} value {value!r} is not one of {allowed}"))
|
||||||
|
elif ftype == "tags":
|
||||||
|
if not isinstance(value, list):
|
||||||
|
problems.append(Problem(name, "wrong-type",
|
||||||
|
f"{name!r} must be a list, got {type(value).__name__}"))
|
||||||
|
elif allowed is not None:
|
||||||
|
for member in value:
|
||||||
|
if str(member) not in allowed:
|
||||||
|
problems.append(Problem(name, "not-in-values",
|
||||||
|
f"{name!r} value {member!r} is not one of {allowed}"))
|
||||||
|
elif ftype == "text":
|
||||||
|
if not _is_scalar(value):
|
||||||
|
problems.append(Problem(name, "wrong-type",
|
||||||
|
f"{name!r} must be a string, got {type(value).__name__}"))
|
||||||
|
return problems
|
||||||
@@ -92,6 +92,95 @@ def restamp_default_project(config: Config) -> None:
|
|||||||
DEFAULT_PROJECT_ID, target, len(pid_tables))
|
DEFAULT_PROJECT_ID, target, len(pid_tables))
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_default_collection_id(config: Config) -> None:
|
||||||
|
"""Heal the §22 migration-029 vs registry-mirror divergence for the default
|
||||||
|
project's collection id on a multi-project deployment.
|
||||||
|
|
||||||
|
Migration 029 seeds each project's default collection id as the literal
|
||||||
|
'default' only when the DB holds a single project at migration time; with
|
||||||
|
≥2 projects it falls back to the *project id* (avoiding a PK collision —
|
||||||
|
029 can't read DEFAULT_PROJECT_ID, there is no env in SQL). But the registry
|
||||||
|
mirror (`registry._default_collection_id`) expects the deployment's default
|
||||||
|
project to own the collection id 'default'. On an upgrade whose DB already
|
||||||
|
held ≥2 projects when 029 ran, the default project's collection is therefore
|
||||||
|
named after the project (e.g. 'ohm'), and the next mirror would INSERT a
|
||||||
|
second, empty 'default' collection instead of merging — duplicating the
|
||||||
|
default corpus and orphaning the entries (which point at 'ohm').
|
||||||
|
|
||||||
|
This is the collection-grain twin of `restamp_default_project`. Run at
|
||||||
|
startup BEFORE the registry mirror so the canonical 'default' collection
|
||||||
|
already exists when the mirror upserts (merge, not duplicate). Renames the
|
||||||
|
divergent collection's id to 'default' and cascades `collection_id` across
|
||||||
|
every collection-keyed table, with FK enforcement off for the atomic rename
|
||||||
|
and a `foreign_key_check` backstop before commit. Idempotent; a no-op on
|
||||||
|
fresh / single-project / already-aligned deployments.
|
||||||
|
"""
|
||||||
|
from .collections import DEFAULT_COLLECTION_ID
|
||||||
|
|
||||||
|
target = resolved_default_id(config)
|
||||||
|
if target == DEFAULT_COLLECTION_ID: # default project already owns 'default'
|
||||||
|
return
|
||||||
|
conn = db.conn()
|
||||||
|
# The 029 ≥2-projects seed names the default project's collection after the
|
||||||
|
# project itself; the canonical id the mirror expects is 'default'.
|
||||||
|
divergent = conn.execute(
|
||||||
|
"SELECT 1 FROM collections WHERE id = ? AND project_id = ? LIMIT 1",
|
||||||
|
(target, target),
|
||||||
|
).fetchone()
|
||||||
|
if not divergent:
|
||||||
|
return
|
||||||
|
if conn.execute(
|
||||||
|
"SELECT 1 FROM collections WHERE id = ? LIMIT 1", (DEFAULT_COLLECTION_ID,)
|
||||||
|
).fetchone():
|
||||||
|
# A 'default' collection already exists (e.g. a prior buggy mirror left a
|
||||||
|
# duplicate). Don't auto-merge data — that needs care; leave both for
|
||||||
|
# operator cleanup and log loudly.
|
||||||
|
log.warning(
|
||||||
|
"reconcile: default project %r owns both a %r and a 'default' "
|
||||||
|
"collection; skipping auto-rename (manual merge required)",
|
||||||
|
target, target,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cid_tables = [
|
||||||
|
t["name"]
|
||||||
|
for t in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
if any(c["name"] == "collection_id"
|
||||||
|
for c in conn.execute(f"PRAGMA table_info({t['name']})"))
|
||||||
|
]
|
||||||
|
conn.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collections SET id = ? WHERE id = ?",
|
||||||
|
(DEFAULT_COLLECTION_ID, target),
|
||||||
|
)
|
||||||
|
for t in cid_tables:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE {t} SET collection_id = ? WHERE collection_id = ?",
|
||||||
|
(DEFAULT_COLLECTION_ID, target),
|
||||||
|
)
|
||||||
|
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||||
|
if violations:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"reconcile 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.info(
|
||||||
|
"reconcile: renamed default-project collection %r -> 'default' across %d tables",
|
||||||
|
target, len(cid_tables),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -19,11 +19,27 @@ lockouts). Both layers run together.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
|
|
||||||
|
|
||||||
|
def _max_events(env_name: str, default: int) -> int:
|
||||||
|
"""Per-limiter budget, overridable via env (e.g. a test/PPE stack that
|
||||||
|
drives the auth endpoints repeatedly from one IP). Production leaves these
|
||||||
|
unset and gets the secure defaults below. A non-positive / unparseable
|
||||||
|
value falls back to the default."""
|
||||||
|
raw = os.environ.get(env_name, "").strip()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
n = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return n if n > 0 else default
|
||||||
|
|
||||||
|
|
||||||
class SlidingWindowLimiter:
|
class SlidingWindowLimiter:
|
||||||
"""Allow at most `max_events` per `window_seconds` per key.
|
"""Allow at most `max_events` per `window_seconds` per key.
|
||||||
|
|
||||||
@@ -66,12 +82,15 @@ class SlidingWindowLimiter:
|
|||||||
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
|
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
|
||||||
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
|
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
|
||||||
# this is defense in depth against a solved-challenge replay loop).
|
# this is defense in depth against a solved-challenge replay loop).
|
||||||
verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300)
|
verify_limiter = SlidingWindowLimiter(
|
||||||
otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300)
|
max_events=_max_events("RATELIMIT_VERIFY_MAX", 10), window_seconds=300)
|
||||||
|
otc_request_limiter = SlidingWindowLimiter(
|
||||||
|
max_events=_max_events("RATELIMIT_OTC_REQUEST_MAX", 5), window_seconds=300)
|
||||||
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
|
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
|
||||||
# It's a legitimate Login-flow affordance, so the budget is generous —
|
# It's a legitimate Login-flow affordance, so the budget is generous —
|
||||||
# enough for a human typing emails, tight enough to stop bulk scraping.
|
# enough for a human typing emails, tight enough to stop bulk scraping.
|
||||||
check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300)
|
check_limiter = SlidingWindowLimiter(
|
||||||
|
max_events=_max_events("RATELIMIT_CHECK_MAX", 30), window_seconds=300)
|
||||||
|
|
||||||
|
|
||||||
def _reset_all_for_tests() -> None:
|
def _reset_all_for_tests() -> None:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from dataclasses import dataclass, field
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
from . import metadata_schema
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .gitea import Gitea
|
from .gitea import Gitea
|
||||||
|
|
||||||
@@ -159,6 +160,13 @@ def parse_collection_manifest(text: str) -> CollectionEntry:
|
|||||||
if not isinstance(em, list):
|
if not isinstance(em, list):
|
||||||
raise RegistryError("collection enabled_models must be a list")
|
raise RegistryError("collection enabled_models must be a list")
|
||||||
cfg["enabled_models"] = [str(m) for m in em]
|
cfg["enabled_models"] = [str(m) for m in em]
|
||||||
|
# §22.4a SLICE-2: the collection's metadata field schema. Parsed leniently —
|
||||||
|
# a bad field def is skipped with a warning, never fatal (INV-3), so a typo
|
||||||
|
# in one field can't drop the whole collection from the mirror. Stored only
|
||||||
|
# when at least one valid field survives.
|
||||||
|
fields = metadata_schema.parse_fields(raw.get("fields"))
|
||||||
|
if fields:
|
||||||
|
cfg["fields"] = fields
|
||||||
return CollectionEntry(ctype, vis, initial_state, name, cfg)
|
return CollectionEntry(ctype, vis, initial_state, name, cfg)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- §22.4a SLICE-1 — configurable collection metadata: malformed-sidecar flag.
|
||||||
|
--
|
||||||
|
-- The corpus mirror reads an entry's metadata from its `<slug>.meta.yaml`
|
||||||
|
-- sidecar (dual-read: sidecar-else-legacy-frontmatter). A sidecar that does not
|
||||||
|
-- parse as a YAML mapping never hard-fails the read (INV-3) — the entry still
|
||||||
|
-- loads (from the legacy `.md` frontmatter if present) and this derived flag
|
||||||
|
-- marks it so the catalog can surface it. Additive only — no rebuild. 0 = ok.
|
||||||
|
ALTER TABLE cached_rfcs ADD COLUMN metadata_malformed INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- §22.4a SLICE-3 — configurable collection metadata: cache per-entry values.
|
||||||
|
--
|
||||||
|
-- Faceted filtering (§5.1) needs each entry's metadata values (priority, custom
|
||||||
|
-- enum/tags fields) to compute facet counts and honour filter params. Today the
|
||||||
|
-- mirror keeps only `tags_json` + the lifecycle columns and drops `Entry.extra`,
|
||||||
|
-- so a declared field's values are unrecoverable. This column persists the full
|
||||||
|
-- per-entry metadata mapping (`metadata.metadata_dict(entry)`, known keys +
|
||||||
|
-- extra, never the body) as JSON, so `app/facets.py` can read any declared
|
||||||
|
-- field uniformly. Additive + nullable — no rebuild. NULL = not yet re-ingested
|
||||||
|
-- (the reconciler/webhook fills it on the next sweep) → that entry contributes
|
||||||
|
-- no facet values until then. SLICE-4/5 edit panels read the same column.
|
||||||
|
ALTER TABLE cached_rfcs ADD COLUMN meta_json TEXT;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""SLICE-4 — bot multi-file commit + PR primitives."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app import gitea as gitea_mod, metadata
|
||||||
|
from app.bot import Actor, Bot
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
LEGACY = "---\nslug: alpha\ntitle: Alpha\nstate: active\ntags:\n- one\n---\n\nBody.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _actor():
|
||||||
|
return Actor(user_id=1, gitea_login="ben.stull", display_name="Ben", email="ben@x.io")
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_entry_files_direct_to_main(app_with_fake_gitea):
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
gitea = gitea_mod.Gitea(load_config())
|
||||||
|
bot = Bot(gitea)
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": LEGACY, "sha": "s1"}
|
||||||
|
st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||||
|
e2 = metadata.apply_values(st.entry, {"tags": ["two"]})
|
||||||
|
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||||
|
asyncio.run(bot.commit_entry_files(
|
||||||
|
_actor(), org="wiggleverse", repo="meta", files=ops,
|
||||||
|
message="Edit metadata", branch="main"))
|
||||||
|
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||||
|
assert "two" in sc
|
||||||
|
# .md is now body-only
|
||||||
|
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_entry_pr_commits_on_branch_and_opens_pr(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
gitea = gitea_mod.Gitea(load_config())
|
||||||
|
bot = Bot(gitea)
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": LEGACY, "sha": "s1"}
|
||||||
|
with TestClient(app): # lifespan inits the DB
|
||||||
|
provision_user_row(user_id=1, login="ben.stull", role="owner")
|
||||||
|
st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||||
|
e2 = metadata.apply_values(st.entry, {"tags": ["two"]})
|
||||||
|
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||||
|
pr = asyncio.run(bot.open_entry_pr(
|
||||||
|
_actor(), org="wiggleverse", repo="meta", slug="alpha", files=ops,
|
||||||
|
pr_title="Metadata: Alpha", pr_description="edit", branch_prefix="metadata"))
|
||||||
|
assert pr["number"] >= 1
|
||||||
|
head = pr["head"]["ref"]
|
||||||
|
assert head.startswith("metadata-alpha-")
|
||||||
|
# committed on the branch, main untouched
|
||||||
|
assert ("wiggleverse", "meta", head, "rfcs/alpha.meta.yaml") in fake.files
|
||||||
|
assert ("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml") not in fake.files
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
subfolder_of."""
|
subfolder_of."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -62,3 +63,23 @@ def test_get_collection_and_subfolder():
|
|||||||
assert collections_mod.subfolder_of("features") == "features"
|
assert collections_mod.subfolder_of("features") == "features"
|
||||||
assert collections_mod.subfolder_of("default") == ""
|
assert collections_mod.subfolder_of("default") == ""
|
||||||
assert collections_mod.get_collection("nope") is None
|
assert collections_mod.get_collection("nope") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- §22.4a SLICE-2: field schema served on the collection ----
|
||||||
|
|
||||||
|
def test_get_collection_fields_none_when_unset():
|
||||||
|
# INV-5: a collection with no `fields:` exposes fields=None (the default
|
||||||
|
# `document` collection sees zero change).
|
||||||
|
_db()
|
||||||
|
_seed()
|
||||||
|
assert collections_mod.get_collection("features")["fields"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_collection_exposes_field_schema():
|
||||||
|
_db()
|
||||||
|
_seed()
|
||||||
|
schema = {"priority": {"type": "enum", "values": ["P0", "P1"]}}
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE collections SET config_json = ? WHERE id = 'features'",
|
||||||
|
(json.dumps({"fields": schema}),))
|
||||||
|
assert collections_mod.get_collection("features")["fields"] == schema
|
||||||
|
|||||||
@@ -61,6 +61,36 @@ def test_parse_collection_manifest_rejects_bad_visibility():
|
|||||||
registry.parse_collection_manifest("type: bdd\nvisibility: nope\n")
|
registry.parse_collection_manifest("type: bdd\nvisibility: nope\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- §22.4a SLICE-2: a `fields:` block flows into the collection config ----
|
||||||
|
|
||||||
|
def test_parse_collection_manifest_reads_field_schema():
|
||||||
|
doc = registry.parse_collection_manifest(
|
||||||
|
"type: bdd\n"
|
||||||
|
"fields:\n"
|
||||||
|
" priority:\n"
|
||||||
|
" type: enum\n"
|
||||||
|
" values: [P0, P1, P2]\n"
|
||||||
|
" tags:\n"
|
||||||
|
" type: tags\n"
|
||||||
|
)
|
||||||
|
assert doc.config["fields"] == {
|
||||||
|
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||||
|
"tags": {"type": "tags"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_collection_manifest_lenient_on_bad_field():
|
||||||
|
# A bad field def is skipped (INV-3), the manifest still parses, and a
|
||||||
|
# manifest with no surviving fields carries no `fields` config key at all.
|
||||||
|
doc = registry.parse_collection_manifest(
|
||||||
|
"type: bdd\n"
|
||||||
|
"fields:\n"
|
||||||
|
" broken:\n"
|
||||||
|
" type: ref\n"
|
||||||
|
)
|
||||||
|
assert "fields" not in doc.config
|
||||||
|
|
||||||
|
|
||||||
# --- mirror discovery ---------------------------------------------------------
|
# --- mirror discovery ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""End-to-end integration tests for the deployed-environment E2E
|
||||||
|
test-auth path (`POST /auth/test/login`).
|
||||||
|
|
||||||
|
This endpoint is a **deliberately gated auth shortcut** for running the
|
||||||
|
Playwright E2E suite against a *deployed* environment (PPE) that has no
|
||||||
|
Mailpit OTC sink and no direct SQLite access to inject an owner row —
|
||||||
|
the two scaffolds the Tier-1 stack relies on. It mints an authenticated
|
||||||
|
**owner** session for a single, pre-configured test identity, but ONLY
|
||||||
|
when the deployment has explicitly opted in by setting BOTH
|
||||||
|
`E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`. It is fail-closed:
|
||||||
|
|
||||||
|
* Off by default — with neither (or only one) env var set, the route
|
||||||
|
is invisible (404), so a production deployment that never sets them
|
||||||
|
cannot be coaxed into minting a session.
|
||||||
|
* Even when enabled, it requires the caller to present the shared
|
||||||
|
secret in the `X-Test-Auth-Secret` header (constant-time compare),
|
||||||
|
and it will only mint a session for the one configured email — any
|
||||||
|
other address is refused (403). So the blast radius of an enabled
|
||||||
|
PPE is a single throwaway owner identity, and the secret is the
|
||||||
|
trust boundary.
|
||||||
|
|
||||||
|
The tests below pin every branch of that gate.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
SECRET = "ppe-e2e-shared-secret-value"
|
||||||
|
EMAIL = "e2e-owner@example.test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_login_is_404_when_disabled(app_with_fake_gitea):
|
||||||
|
"""Neither env var set (the default, incl. production) → the route
|
||||||
|
does not exist."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.post(
|
||||||
|
"/auth/test/login",
|
||||||
|
json={"email": EMAIL},
|
||||||
|
headers={"X-Test-Auth-Secret": SECRET},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_login_is_404_when_only_email_is_set(app_with_fake_gitea, monkeypatch):
|
||||||
|
"""Half-configured (email but no secret) must NOT open the route —
|
||||||
|
a framework auth shortcut gated only by a known email would be far
|
||||||
|
too weak."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||||
|
monkeypatch.delenv("E2E_TEST_AUTH_SECRET", raising=False)
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.post(
|
||||||
|
"/auth/test/login",
|
||||||
|
json={"email": EMAIL},
|
||||||
|
headers={"X-Test-Auth-Secret": SECRET},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_login_refuses_wrong_secret(app_with_fake_gitea, monkeypatch):
|
||||||
|
"""Enabled, but a bad/absent secret → 404 (don't advertise the
|
||||||
|
route's existence to an unauthenticated caller)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Wrong secret.
|
||||||
|
r = client.post(
|
||||||
|
"/auth/test/login",
|
||||||
|
json={"email": EMAIL},
|
||||||
|
headers={"X-Test-Auth-Secret": "not-the-secret"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
# Absent secret.
|
||||||
|
r = client.post("/auth/test/login", json={"email": EMAIL})
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_login_refuses_unconfigured_email(app_with_fake_gitea, monkeypatch):
|
||||||
|
"""Right secret but an email other than the single configured
|
||||||
|
identity → 403. Even a secret-bearer can only mint the one test
|
||||||
|
owner."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.post(
|
||||||
|
"/auth/test/login",
|
||||||
|
json={"email": "someone-else@example.test"},
|
||||||
|
headers={"X-Test-Auth-Secret": SECRET},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_login_mints_owner_session(app_with_fake_gitea, monkeypatch):
|
||||||
|
"""The happy path: right secret + configured email → an authenticated
|
||||||
|
session whose user is a GRANTED OWNER (so the metadata write paths —
|
||||||
|
SLICE-4 edit, SLICE-5 bulk — accept it), persisted on a fresh
|
||||||
|
`users` row."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.post(
|
||||||
|
"/auth/test/login",
|
||||||
|
json={"email": EMAIL},
|
||||||
|
headers={"X-Test-Auth-Secret": SECRET},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# The session cookie now surfaces an authenticated owner.
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is True
|
||||||
|
assert me["user"]["email"] == EMAIL
|
||||||
|
assert me["user"]["role"] == "owner"
|
||||||
|
assert me["user"]["permission_state"] == "granted"
|
||||||
|
|
||||||
|
# Idempotent: a second login reuses the same row (still owner).
|
||||||
|
r = client.post(
|
||||||
|
"/auth/test/login",
|
||||||
|
json={"email": EMAIL},
|
||||||
|
headers={"X-Test-Auth-Secret": SECRET},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"SELECT role, permission_state FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
(EMAIL,),
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["role"] == "owner"
|
||||||
|
assert rows[0]["permission_state"] == "granted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_login_is_case_insensitive_on_email(app_with_fake_gitea, monkeypatch):
|
||||||
|
"""The configured-email check matches case-insensitively, mirroring
|
||||||
|
how the rest of the auth stack treats email."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
|
||||||
|
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.post(
|
||||||
|
"/auth/test/login",
|
||||||
|
json={"email": EMAIL.upper()},
|
||||||
|
headers={"X-Test-Auth-Secret": SECRET},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""§22.4a SLICE-3 — pure facet field-set + filter/count (PUC-3).
|
||||||
|
|
||||||
|
Per docs/design/2026-06-06-configurable-collection-metadata.md §5.1, §6.4.
|
||||||
|
"""
|
||||||
|
from app import facets
|
||||||
|
|
||||||
|
|
||||||
|
PRIORITY = {"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}}
|
||||||
|
SCHEMA = {
|
||||||
|
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||||
|
"tags": {"type": "tags"},
|
||||||
|
"owner": {"type": "text"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _e(slug, state="active", malformed=False, **meta):
|
||||||
|
return {"slug": slug, "state": state, "metadata_malformed": malformed, "meta": meta}
|
||||||
|
|
||||||
|
|
||||||
|
def test_facet_fields_orders_declared_then_state_skips_text():
|
||||||
|
# enum + tags in declaration order, text skipped, state appended last.
|
||||||
|
assert facets.facet_fields(SCHEMA) == [
|
||||||
|
("priority", "enum"), ("tags", "tags"), ("state", "enum")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_schema_yields_no_facets():
|
||||||
|
# INV-5: a collection with no fields has no facets (frontend keeps chips).
|
||||||
|
assert facets.facet_fields(None) == []
|
||||||
|
assert facets.facet_fields({}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_and_count_basic_counts():
|
||||||
|
entries = [
|
||||||
|
_e("a", priority="P0", tags=["checkout"]),
|
||||||
|
_e("b", priority="P0", tags=["cart"]),
|
||||||
|
_e("c", priority="P1", tags=["checkout", "cart"]),
|
||||||
|
]
|
||||||
|
items, fac = facets.filter_and_count(entries, SCHEMA, {})
|
||||||
|
assert {i["slug"] for i in items} == {"a", "b", "c"}
|
||||||
|
assert fac["priority"] == {"P0": 2, "P1": 1}
|
||||||
|
assert fac["tags"] == {"checkout": 2, "cart": 2}
|
||||||
|
assert fac["state"] == {"active": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_compose_or_within_and_across():
|
||||||
|
entries = [
|
||||||
|
_e("a", priority="P0", tags=["checkout"]), # P0 + checkout
|
||||||
|
_e("b", priority="P0", tags=["cart"]), # P0, no checkout
|
||||||
|
_e("c", priority="P1", tags=["checkout"]), # checkout, not P0
|
||||||
|
]
|
||||||
|
# priority=P0 AND tags=checkout → only "a".
|
||||||
|
items, _ = facets.filter_and_count(
|
||||||
|
entries, SCHEMA, {"priority": {"P0"}, "tags": {"checkout"}})
|
||||||
|
assert {i["slug"] for i in items} == {"a"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_drilldown_counts_exclude_own_field_selection():
|
||||||
|
entries = [
|
||||||
|
_e("a", priority="P0"),
|
||||||
|
_e("b", priority="P1"),
|
||||||
|
_e("c", priority="P1"),
|
||||||
|
]
|
||||||
|
# With P0 selected, the priority facet still counts P1 over the set that
|
||||||
|
# ignores priority's own selection — so P1 stays switchable.
|
||||||
|
_, fac = facets.filter_and_count(entries, SCHEMA, {"priority": {"P0"}})
|
||||||
|
assert fac["priority"] == {"P0": 1, "P1": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_toggle_narrows_items_and_counts():
|
||||||
|
entries = [
|
||||||
|
_e("a", state="active", malformed=True, priority="P9"),
|
||||||
|
_e("b", state="active", malformed=False, priority="P0"),
|
||||||
|
]
|
||||||
|
items, fac = facets.filter_and_count(entries, SCHEMA, {}, only_malformed=True)
|
||||||
|
assert {i["slug"] for i in items} == {"a"}
|
||||||
|
assert fac["priority"] == {"P9": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_value_contributes_no_facet_value():
|
||||||
|
entries = [_e("a", priority="P0"), _e("b")] # b has no priority
|
||||||
|
_, fac = facets.filter_and_count(entries, SCHEMA, {})
|
||||||
|
assert fac["priority"] == {"P0": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_filter_keys():
|
||||||
|
assert facets.allowed_filter_keys(PRIORITY) == {"priority", "state",
|
||||||
|
"unreviewed", "malformed"}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""§22.4a SLICE-3 integration — faceted list endpoint (PUC-3, §6.4).
|
||||||
|
|
||||||
|
Through the real API: schema-declared facets, filter params (OR within / AND
|
||||||
|
across), drill-down counts, malformed toggle, unknown-field 400, and meta_json
|
||||||
|
persistence. Reuses the fake-Gitea harness from test_metadata_cache.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import cache, db, gitea as gitea_mod
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||||
|
app_with_fake_gitea,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The fake-Gitea harness seeds a single project whose id is the literal
|
||||||
|
# 'default' (see test_s1_collection_grain_vertical), served at
|
||||||
|
# /api/projects/default/rfcs.
|
||||||
|
PID = "default"
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh():
|
||||||
|
cfg = load_config()
|
||||||
|
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_default_fields_schema(schema):
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||||
|
(json.dumps({"fields": schema}),))
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(fake, slug, *, state="active", **front):
|
||||||
|
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
|
||||||
|
body = yaml.safe_dump(fm, sort_keys=False).strip()
|
||||||
|
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||||
|
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
|
||||||
|
|
||||||
|
|
||||||
|
def test_facets_and_counts_returned(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_default_fields_schema({
|
||||||
|
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||||
|
"tags": {"type": "tags"},
|
||||||
|
})
|
||||||
|
_seed(fake, "a", priority="P0", tags=["checkout"])
|
||||||
|
_seed(fake, "b", priority="P0", tags=["cart"])
|
||||||
|
_seed(fake, "c", priority="P1", tags=["checkout", "cart"])
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
res = client.get(f"/api/projects/{PID}/rfcs")
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert {i["slug"] for i in body["items"]} == {"a", "b", "c"}
|
||||||
|
assert body["facets"]["priority"] == {"P0": 2, "P1": 1}
|
||||||
|
assert body["facets"]["tags"] == {"checkout": 2, "cart": 2}
|
||||||
|
assert body["facets"]["state"] == {"active": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_params_compose(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_default_fields_schema({
|
||||||
|
"priority": {"type": "enum", "values": ["P0", "P1"]},
|
||||||
|
"tags": {"type": "tags"},
|
||||||
|
})
|
||||||
|
_seed(fake, "a", priority="P0", tags=["checkout"])
|
||||||
|
_seed(fake, "b", priority="P0", tags=["cart"])
|
||||||
|
_seed(fake, "c", priority="P1", tags=["checkout"])
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
res = client.get(
|
||||||
|
f"/api/projects/{PID}/rfcs",
|
||||||
|
params={"priority": "P0", "tags": "checkout"})
|
||||||
|
assert {i["slug"] for i in res.json()["items"]} == {"a"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_filter_field_400(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_default_fields_schema(
|
||||||
|
{"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
res = client.get(f"/api/projects/{PID}/rfcs",
|
||||||
|
params={"nonsense": "x"})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_toggle(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_default_fields_schema(
|
||||||
|
{"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
_seed(fake, "good", priority="P0")
|
||||||
|
_seed(fake, "bad", priority="P9") # not in values → malformed (INV-3)
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
res = client.get(f"/api/projects/{PID}/rfcs",
|
||||||
|
params={"malformed": "true"})
|
||||||
|
slugs = {i["slug"] for i in res.json()["items"]}
|
||||||
|
assert slugs == {"bad"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_schema_no_facets(app_with_fake_gitea):
|
||||||
|
# INV-5: the default document collection (no fields) returns empty facets.
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_seed(fake, "plain", tags=["whatever"])
|
||||||
|
_refresh()
|
||||||
|
body = client.get(f"/api/projects/{PID}/rfcs").json()
|
||||||
|
assert body["facets"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_meta_json_persisted_at_ingest(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
_seed(fake, "withmeta", priority="P0", tags=["x"])
|
||||||
|
_refresh()
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT meta_json FROM cached_rfcs WHERE slug = 'withmeta'"
|
||||||
|
).fetchone()
|
||||||
|
meta = json.loads(row["meta_json"])
|
||||||
|
assert meta["priority"] == "P0"
|
||||||
|
assert meta["tags"] == ["x"]
|
||||||
@@ -48,6 +48,18 @@ PITCH = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_from_git(fake, slug, branch="main"):
|
||||||
|
"""§22.4a SLICE-4: read an entry's combined metadata+body from git via the
|
||||||
|
dual-read parser — graduation/claim now write metadata to the sidecar and
|
||||||
|
keep the body in the `.md`, so an Entry is reconstructed from both."""
|
||||||
|
from app import metadata
|
||||||
|
md = fake.files[("wiggleverse", "meta", branch, f"rfcs/{slug}.md")]["content"]
|
||||||
|
sc = fake.files.get(
|
||||||
|
("wiggleverse", "meta", branch, f"rfcs/{slug}.meta.yaml"), {}).get("content")
|
||||||
|
e, _ = metadata.read_entry(md, sc, fallback_slug=slug)
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
|
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
|
||||||
owners: list[str], arbiters: list[str] | None = None,
|
owners: list[str], arbiters: list[str] | None = None,
|
||||||
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
|
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
|
||||||
@@ -190,9 +202,8 @@ def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
|
|||||||
k[1].startswith("rfc-0042") for k in fake.repos
|
k[1].startswith("rfc-0042") for k in fake.repos
|
||||||
), f"a per-RFC repo was created: {fake.repos}"
|
), f"a per-RFC repo was created: {fake.repos}"
|
||||||
|
|
||||||
# Meta entry on main: state flipped, body KEPT, repo null.
|
# Meta entry on main: state flipped (sidecar), body KEPT (.md), repo null.
|
||||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
graduated = _entry_from_git(fake, "ohm")
|
||||||
graduated = entry_mod.parse(meta_text)
|
|
||||||
assert graduated.state == "active"
|
assert graduated.state == "active"
|
||||||
assert graduated.id == "RFC-0042"
|
assert graduated.id == "RFC-0042"
|
||||||
assert graduated.repo is None
|
assert graduated.repo is None
|
||||||
@@ -224,6 +235,34 @@ def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
|
|||||||
assert gone not in kinds, f"retired audit row present: {gone}"
|
assert gone not in kinds, f"retired audit row present: {gone}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_graduate_preserves_unknown_frontmatter_keys(app_with_fake_gitea):
|
||||||
|
"""§22.4a INV-7: a forward-compat / unknown frontmatter key on the
|
||||||
|
super-draft entry must ride through the graduation rebuild, not be dropped."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import entry as entry_mod
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
|
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH,
|
||||||
|
owners=["ben"], arbiters=["ben"])
|
||||||
|
# Inject an unknown key into the seeded entry's frontmatter.
|
||||||
|
key = ("wiggleverse", "meta", "main", "rfcs/ohm.md")
|
||||||
|
e = entry_mod.parse(fake.files[key]["content"])
|
||||||
|
e.extra["priority"] = "P1"
|
||||||
|
fake.files[key]["content"] = entry_mod.serialize(e)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||||
|
display_name="Ben", role="owner", email="ben@test")
|
||||||
|
r = client.post("/api/rfcs/ohm/graduate?_sync=1",
|
||||||
|
json={"rfc_id": "RFC-0042", "owners": ["ben"]})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
graduated = _entry_from_git(fake, "ohm")
|
||||||
|
assert graduated.state == "active"
|
||||||
|
assert graduated.extra.get("priority") == "P1"
|
||||||
|
|
||||||
|
|
||||||
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
|
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
|
||||||
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
|
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
|
||||||
graduation — the body is kept, so they coexist. /check stays
|
graduation — the body is kept, so they coexist. /check stays
|
||||||
@@ -571,8 +610,7 @@ def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_g
|
|||||||
assert d["rfc_id"] is None
|
assert d["rfc_id"] is None
|
||||||
|
|
||||||
# Meta entry: active, id null, body kept, graduation stamped.
|
# Meta entry: active, id null, body kept, graduation stamped.
|
||||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
graduated = _entry_from_git(fake, "ohm")
|
||||||
graduated = entry_mod.parse(meta_text)
|
|
||||||
assert graduated.state == "active"
|
assert graduated.state == "active"
|
||||||
assert graduated.id is None
|
assert graduated.id is None
|
||||||
assert graduated.graduated_by == "ben"
|
assert graduated.graduated_by == "ben"
|
||||||
@@ -621,9 +659,7 @@ def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea
|
|||||||
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
|
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert r.json()["rfc_id"] is None
|
assert r.json()["rfc_id"] is None
|
||||||
graduated = entry_mod.parse(
|
graduated = _entry_from_git(fake, "ohm")
|
||||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
|
||||||
)
|
|
||||||
assert graduated.state == "active"
|
assert graduated.state == "active"
|
||||||
assert graduated.id is None
|
assert graduated.id is None
|
||||||
|
|
||||||
@@ -649,8 +685,7 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
|
|||||||
d = r.json()
|
d = r.json()
|
||||||
assert d["branch_name"] == "claim/ohm"
|
assert d["branch_name"] == "claim/ohm"
|
||||||
|
|
||||||
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
ent = _entry_from_git(fake, "ohm", branch="claim/ohm")
|
||||||
ent = entry_mod.parse(text)
|
|
||||||
assert "alice" in ent.owners
|
assert "alice" in ent.owners
|
||||||
|
|
||||||
row = db.conn().execute(
|
row = db.conn().execute(
|
||||||
|
|||||||
@@ -47,12 +47,15 @@ def test_mark_reviewed_clears_flag(app_with_fake_gitea):
|
|||||||
assert row["unreviewed"] == 0
|
assert row["unreviewed"] == 0
|
||||||
assert row["reviewed_by"] == "ben"
|
assert row["reviewed_by"] == "ben"
|
||||||
assert row["reviewed_at"] # provenance stamped
|
assert row["reviewed_at"] # provenance stamped
|
||||||
# git-side: the entry file on main was rewritten with the cleared flag.
|
# git-side (§22.4a SLICE-4): the cleared flag now lands in the metadata
|
||||||
from app import entry as entry_mod
|
# sidecar and the `.md` is lazy-migrated to a clean body-only file (INV-2).
|
||||||
|
import yaml
|
||||||
|
sidecar = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.meta.yaml")]["content"]
|
||||||
|
sc = yaml.safe_load(sidecar)
|
||||||
|
assert not sc.get("unreviewed") # cleared (omitted when False)
|
||||||
|
assert sc.get("reviewed_by") == "ben"
|
||||||
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
|
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
|
||||||
e = entry_mod.parse(written)
|
assert "---" not in written # body-only, no frontmatter
|
||||||
assert e.unreviewed is False
|
|
||||||
assert e.reviewed_by == "ben"
|
|
||||||
|
|
||||||
|
|
||||||
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
|
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""SLICE-1 unit tests — sidecar metadata: dual-read, unknown-key preservation,
|
||||||
|
frontmatter stripping, malformed detection.
|
||||||
|
|
||||||
|
Pure functions only (no DB / no Gitea). Per
|
||||||
|
docs/design/2026-06-06-configurable-collection-metadata.md §7.2 (SLICE-1) and
|
||||||
|
INV-6 (dual-read), INV-7 (unknown keys ride along), INV-2 (clean body).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app import entry as entry_mod
|
||||||
|
from app import metadata
|
||||||
|
|
||||||
|
|
||||||
|
LEGACY_MD = """---
|
||||||
|
slug: view-metrics
|
||||||
|
title: View today's metrics
|
||||||
|
state: active
|
||||||
|
owners:
|
||||||
|
- ben.stull
|
||||||
|
tags:
|
||||||
|
- dashboard
|
||||||
|
- analytics
|
||||||
|
priority: P1
|
||||||
|
owner: hasan
|
||||||
|
---
|
||||||
|
|
||||||
|
This is the prose body.
|
||||||
|
|
||||||
|
Second paragraph.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---- INV-7: unknown keys ride along ----
|
||||||
|
|
||||||
|
def test_parse_preserves_unknown_keys_in_extra():
|
||||||
|
e = entry_mod.parse(LEGACY_MD)
|
||||||
|
assert e.extra == {"priority": "P1", "owner": "hasan"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_round_trip_preserves_unknown_keys():
|
||||||
|
e = entry_mod.parse(LEGACY_MD)
|
||||||
|
text = entry_mod.serialize(e)
|
||||||
|
e2 = entry_mod.parse(text)
|
||||||
|
assert e2.extra == {"priority": "P1", "owner": "hasan"}
|
||||||
|
assert e2.tags == ["dashboard", "analytics"]
|
||||||
|
assert e2.owners == ["ben.stull"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_keys_never_leak_into_extra():
|
||||||
|
e = entry_mod.parse(LEGACY_MD)
|
||||||
|
for known in ("slug", "title", "state", "owners", "tags"):
|
||||||
|
assert known not in e.extra
|
||||||
|
|
||||||
|
|
||||||
|
# ---- metadata_dict / sidecar_yaml ----
|
||||||
|
|
||||||
|
def test_metadata_dict_merges_known_and_extra():
|
||||||
|
e = entry_mod.parse(LEGACY_MD)
|
||||||
|
d = metadata.metadata_dict(e)
|
||||||
|
assert d["slug"] == "view-metrics"
|
||||||
|
assert d["title"] == "View today's metrics"
|
||||||
|
assert d["state"] == "active"
|
||||||
|
assert d["tags"] == ["dashboard", "analytics"]
|
||||||
|
# forward-compat keys present
|
||||||
|
assert d["priority"] == "P1"
|
||||||
|
assert d["owner"] == "hasan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidecar_yaml_is_parseable_and_has_no_frontmatter_fences():
|
||||||
|
e = entry_mod.parse(LEGACY_MD)
|
||||||
|
sc = metadata.sidecar_yaml(e)
|
||||||
|
assert "---" not in sc.splitlines()[0]
|
||||||
|
loaded = yaml.safe_load(sc)
|
||||||
|
assert loaded["slug"] == "view-metrics"
|
||||||
|
assert loaded["priority"] == "P1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- strip_frontmatter (INV-2) ----
|
||||||
|
|
||||||
|
def test_strip_frontmatter_removes_leading_block():
|
||||||
|
body = metadata.strip_frontmatter(LEGACY_MD)
|
||||||
|
assert body.startswith("This is the prose body.")
|
||||||
|
assert "slug:" not in body
|
||||||
|
assert "priority:" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_frontmatter_passthrough_when_no_frontmatter():
|
||||||
|
plain = "Just a body.\n\nNo frontmatter here.\n"
|
||||||
|
assert metadata.strip_frontmatter(plain).strip() == plain.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- parse_sidecar (malformed detection, INV-3) ----
|
||||||
|
|
||||||
|
def test_parse_sidecar_good():
|
||||||
|
values, malformed = metadata.parse_sidecar("slug: a\ntitle: A\npriority: P0\n")
|
||||||
|
assert malformed is False
|
||||||
|
assert values == {"slug": "a", "title": "A", "priority": "P0"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sidecar_non_mapping_is_malformed():
|
||||||
|
values, malformed = metadata.parse_sidecar("- just\n- a\n- list\n")
|
||||||
|
assert malformed is True
|
||||||
|
assert values == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sidecar_invalid_yaml_is_malformed():
|
||||||
|
values, malformed = metadata.parse_sidecar("slug: : : not yaml\n bad: [unclosed\n")
|
||||||
|
assert malformed is True
|
||||||
|
assert values == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sidecar_empty_is_empty_not_malformed():
|
||||||
|
values, malformed = metadata.parse_sidecar("")
|
||||||
|
assert malformed is False
|
||||||
|
assert values == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- read_entry dual-read equivalence (INV-6) ----
|
||||||
|
|
||||||
|
def test_dual_read_sidecar_matches_legacy():
|
||||||
|
legacy_entry, legacy_bad = metadata.read_entry(LEGACY_MD, None)
|
||||||
|
|
||||||
|
# The migrated form: body-only .md + a sidecar holding the metadata.
|
||||||
|
migrated_md = metadata.strip_frontmatter(LEGACY_MD)
|
||||||
|
sidecar_text = metadata.sidecar_yaml(legacy_entry)
|
||||||
|
sidecar_entry, sidecar_bad = metadata.read_entry(migrated_md, sidecar_text)
|
||||||
|
|
||||||
|
assert legacy_bad is False
|
||||||
|
assert sidecar_bad is False
|
||||||
|
# Identical resulting records (INV-6).
|
||||||
|
assert sidecar_entry.slug == legacy_entry.slug
|
||||||
|
assert sidecar_entry.title == legacy_entry.title
|
||||||
|
assert sidecar_entry.state == legacy_entry.state
|
||||||
|
assert sidecar_entry.owners == legacy_entry.owners
|
||||||
|
assert sidecar_entry.tags == legacy_entry.tags
|
||||||
|
assert sidecar_entry.extra == legacy_entry.extra
|
||||||
|
assert sidecar_entry.body.strip() == legacy_entry.body.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_entry_sidecar_takes_precedence_over_md_frontmatter():
|
||||||
|
# A not-yet-migrated .md still carrying frontmatter, plus a sidecar that
|
||||||
|
# disagrees: the sidecar wins for metadata; the body comes from the .md.
|
||||||
|
md_with_fm = "---\nslug: old\ntitle: Old Title\nstate: super-draft\n---\n\nBody.\n"
|
||||||
|
sidecar = "slug: new\ntitle: New Title\nstate: active\n"
|
||||||
|
e, malformed = metadata.read_entry(md_with_fm, sidecar)
|
||||||
|
assert malformed is False
|
||||||
|
assert e.title == "New Title"
|
||||||
|
assert e.state == "active"
|
||||||
|
assert e.body.strip() == "Body."
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_entry_malformed_sidecar_still_loads_entry():
|
||||||
|
# INV-3: a malformed sidecar never hard-fails the read. The entry loads
|
||||||
|
# (from the .md frontmatter if present) and is flagged malformed.
|
||||||
|
md = "---\nslug: x\ntitle: X\nstate: active\n---\n\nBody.\n"
|
||||||
|
e, malformed = metadata.read_entry(md, "- not a mapping\n")
|
||||||
|
assert malformed is True
|
||||||
|
assert e.slug == "x"
|
||||||
|
assert e.title == "X"
|
||||||
|
assert e.body.strip() == "Body."
|
||||||
|
|
||||||
|
|
||||||
|
# ---- dual-read robustness: degenerate sidecars never drop the entry ----
|
||||||
|
|
||||||
|
def test_empty_sidecar_falls_back_to_md_frontmatter():
|
||||||
|
# An empty sidecar has no metadata to override with — keep the .md's.
|
||||||
|
md = "---\nslug: keep\ntitle: Keep Me\nstate: active\n---\n\nBody.\n"
|
||||||
|
e, malformed = metadata.read_entry(md, "", fallback_slug="keep")
|
||||||
|
assert malformed is False
|
||||||
|
assert e.slug == "keep"
|
||||||
|
assert e.title == "Keep Me"
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_sidecar_on_body_only_md_loads_with_fallback_slug():
|
||||||
|
# The .md is already body-only (migrated) and the sidecar is corrupt:
|
||||||
|
# the entry must still load (INV-3), taking its slug from the filename stem.
|
||||||
|
e, malformed = metadata.read_entry("Just a body.\n", "- a\n- list\n", fallback_slug="foo")
|
||||||
|
assert malformed is True
|
||||||
|
assert e.slug == "foo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugless_sidecar_uses_fallback_slug():
|
||||||
|
md = "Body only.\n"
|
||||||
|
sidecar = "title: No Slug Here\nstate: active\n"
|
||||||
|
e, malformed = metadata.read_entry(md, sidecar, fallback_slug="bar")
|
||||||
|
assert malformed is False
|
||||||
|
assert e.slug == "bar"
|
||||||
|
assert e.title == "No Slug Here"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- sidecar filename helpers ----
|
||||||
|
|
||||||
|
def test_sidecar_filename_helpers():
|
||||||
|
assert metadata.sidecar_name("view-metrics") == "view-metrics.meta.yaml"
|
||||||
|
assert metadata.is_sidecar("view-metrics.meta.yaml") is True
|
||||||
|
assert metadata.is_sidecar("view-metrics.md") is False
|
||||||
|
assert metadata.slug_of_sidecar("view-metrics.meta.yaml") == "view-metrics"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- SLICE-4: sidecar_path_for + apply_values ----
|
||||||
|
|
||||||
|
def test_sidecar_path_for_derives_sibling():
|
||||||
|
assert metadata.sidecar_path_for("rfcs/alpha.md") == "rfcs/alpha.meta.yaml"
|
||||||
|
assert metadata.sidecar_path_for("x/y/beta.md") == "x/y/beta.meta.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_values_updates_known_and_extra_fields():
|
||||||
|
e = entry_mod.parse(LEGACY_MD) # has tags + extra priority/owner
|
||||||
|
e2 = metadata.apply_values(e, {"tags": ["x"], "priority": "P0", "owner": "sam"})
|
||||||
|
assert e2.tags == ["x"]
|
||||||
|
assert e2.extra["priority"] == "P0"
|
||||||
|
assert e2.extra["owner"] == "sam"
|
||||||
|
# body preserved unchanged
|
||||||
|
assert e2.body == e.body
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_values_preserves_unspecified_keys():
|
||||||
|
e = entry_mod.parse(LEGACY_MD)
|
||||||
|
e2 = metadata.apply_values(e, {"priority": "P0"})
|
||||||
|
assert e2.tags == e.tags # untouched
|
||||||
|
assert e2.extra["owner"] == "hasan" # untouched
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""SLICE-5 — bulk metadata edit endpoint (PUC-2, §6.4/§6.5).
|
||||||
|
|
||||||
|
Through the real API: contributor+ gating (INV-4), set/add/remove ops,
|
||||||
|
validation at the write boundary, one commit for N sidecars (D7), and
|
||||||
|
partial-rejection reporting. Reuses the fake-Gitea harness.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import cache, db, gitea as gitea_mod
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
PID = "default"
|
||||||
|
CID = "default"
|
||||||
|
BASE = f"/api/projects/{PID}/collections/{CID}"
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh():
|
||||||
|
cfg = load_config()
|
||||||
|
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_fields(schema):
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||||
|
(json.dumps({"fields": schema}),))
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_legacy(fake, slug, *, state="active", **front):
|
||||||
|
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
|
||||||
|
body = yaml.safe_dump(fm, sort_keys=False).strip()
|
||||||
|
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||||
|
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
|
||||||
|
|
||||||
|
|
||||||
|
def _login_owner(client):
|
||||||
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_sidecar(fake, slug):
|
||||||
|
return ("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml") in fake.files
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar(fake, slug):
|
||||||
|
return yaml.safe_load(
|
||||||
|
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")]["content"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Task 1: happy path, one commit ----
|
||||||
|
|
||||||
|
def test_bulk_set_applies_to_all_and_one_commit(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P2")
|
||||||
|
_seed_legacy(fake, "b", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
commits_before = fake.change_files_calls
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a", "b"], "op": "set",
|
||||||
|
"field": "priority", "value": "P0"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert set(body["applied"]) == {"a", "b"}
|
||||||
|
assert body["rejected"] == []
|
||||||
|
assert body["committed"] is True
|
||||||
|
# exactly one ChangeFiles commit covered both entries (D7)
|
||||||
|
assert fake.change_files_calls - commits_before == 1
|
||||||
|
assert _sidecar(fake, "a")["priority"] == "P0"
|
||||||
|
assert _sidecar(fake, "b")["priority"] == "P0"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Task 2: add/remove tags ----
|
||||||
|
|
||||||
|
def test_bulk_add_tag(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"tags": {"type": "tags"}})
|
||||||
|
_seed_legacy(fake, "a", tags=["x"])
|
||||||
|
_seed_legacy(fake, "b", tags=["x", "y"])
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a", "b"], "op": "add",
|
||||||
|
"field": "tags", "value": "y"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert set(r.json()["applied"]) == {"a", "b"}
|
||||||
|
# "a" gained y; "b" already had y (no-op write skipped → no sidecar written)
|
||||||
|
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
|
||||||
|
assert not _has_sidecar(fake, "b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_remove_tag(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"tags": {"type": "tags"}})
|
||||||
|
_seed_legacy(fake, "a", tags=["x", "y"])
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "remove",
|
||||||
|
"field": "tags", "value": "x"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["applied"] == ["a"]
|
||||||
|
assert _sidecar(fake, "a")["tags"] == ["y"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_set_scalar_on_tags_rejected(app_with_fake_gitea):
|
||||||
|
# A scalar `set` onto a tags field must reject, not char-split into a list.
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"tags": {"type": "tags"}})
|
||||||
|
_seed_legacy(fake, "a", tags=["x"])
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "set",
|
||||||
|
"field": "tags", "value": "checkout"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["applied"] == []
|
||||||
|
assert len(r.json()["rejected"]) == 1
|
||||||
|
assert r.json()["committed"] is False
|
||||||
|
assert not _has_sidecar(fake, "a")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_set_list_on_tags_ok(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"tags": {"type": "tags"}})
|
||||||
|
_seed_legacy(fake, "a", tags=["x"])
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "set",
|
||||||
|
"field": "tags", "value": ["x", "y"]})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["applied"] == ["a"]
|
||||||
|
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_add_remove_requires_tags_field(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "add",
|
||||||
|
"field": "priority", "value": "z"})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Task 3: partial rejection, authz, validation guards ----
|
||||||
|
|
||||||
|
def test_bulk_partial_reject_missing_entry(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a", "ghost"], "op": "set",
|
||||||
|
"field": "priority", "value": "P0"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["applied"] == ["a"]
|
||||||
|
assert body["rejected"] == [{"slug": "ghost", "reason": "not found"}]
|
||||||
|
assert body["committed"] is True
|
||||||
|
assert _sidecar(fake, "a")["priority"] == "P0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_invalid_value_rejects_all_no_commit(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "set",
|
||||||
|
"field": "priority", "value": "ZZZ"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["applied"] == []
|
||||||
|
assert len(r.json()["rejected"]) == 1
|
||||||
|
assert r.json()["committed"] is False
|
||||||
|
assert not _has_sidecar(fake, "a")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_forbidden_for_anonymous(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "set",
|
||||||
|
"field": "priority", "value": "P0"})
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_unknown_field_op_and_empty(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
assert client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "set",
|
||||||
|
"field": "nope", "value": "P0"}).status_code == 422
|
||||||
|
assert client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "frobnicate",
|
||||||
|
"field": "priority", "value": "P0"}).status_code == 422
|
||||||
|
assert client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": [], "op": "set",
|
||||||
|
"field": "priority", "value": "P0"}).status_code == 422
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""SLICE-1 integration — the corpus mirror reads sidecars (dual-read) and
|
||||||
|
derives the malformed flag (PUC-6, INV-3/INV-6).
|
||||||
|
|
||||||
|
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.2-6.3.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import cache, db, gitea as gitea_mod
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||||
|
app_with_fake_gitea,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh():
|
||||||
|
cfg = load_config()
|
||||||
|
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||||
|
|
||||||
|
|
||||||
|
def _row(slug):
|
||||||
|
return db.conn().execute(
|
||||||
|
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mirror_reads_metadata_from_sidecar(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
# A migrated entry: body-only .md + a sidecar holding the metadata.
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/sidecar-one.md")] = {
|
||||||
|
"content": "Just the prose body.\n", "sha": "s1"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/sidecar-one.meta.yaml")] = {
|
||||||
|
"content": "slug: sidecar-one\ntitle: From Sidecar\nstate: active\ntags:\n- alpha\n",
|
||||||
|
"sha": "m1"}
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
row = _row("sidecar-one")
|
||||||
|
assert row is not None
|
||||||
|
assert row["title"] == "From Sidecar"
|
||||||
|
assert row["state"] == "active"
|
||||||
|
assert row["body"].strip() == "Just the prose body."
|
||||||
|
assert row["metadata_malformed"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_sidecar_flags_but_still_loads(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
# .md still has frontmatter; sidecar is malformed (a list, not a map).
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-meta.md")] = {
|
||||||
|
"content": "---\nslug: bad-meta\ntitle: Legacy Title\nstate: active\n---\n\nBody.\n",
|
||||||
|
"sha": "b1"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-meta.meta.yaml")] = {
|
||||||
|
"content": "- not\n- a\n- mapping\n", "sha": "b2"}
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
row = _row("bad-meta")
|
||||||
|
assert row is not None # INV-3: still loads
|
||||||
|
assert row["metadata_malformed"] == 1
|
||||||
|
# Falls back to the legacy .md frontmatter for the metadata.
|
||||||
|
assert row["title"] == "Legacy Title"
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_flag_surfaces_in_catalog_api(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/flagged.md")] = {
|
||||||
|
"content": "---\nslug: flagged\ntitle: Flagged\nstate: active\n---\n\nB.\n",
|
||||||
|
"sha": "f1"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/flagged.meta.yaml")] = {
|
||||||
|
"content": "just a scalar\n", "sha": "f2"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/clean.md")] = {
|
||||||
|
"content": "---\nslug: clean\ntitle: Clean\nstate: active\n---\n\nB.\n",
|
||||||
|
"sha": "c1"}
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
items = {i["slug"]: i for i in client.get("/api/rfcs").json()["items"]}
|
||||||
|
assert items["flagged"]["metadata_malformed"] is True
|
||||||
|
assert items["clean"]["metadata_malformed"] is False
|
||||||
|
|
||||||
|
# And on the detail view.
|
||||||
|
assert client.get("/api/rfcs/flagged").json()["metadata_malformed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_sidecar_on_migrated_entry_still_loads_flagged(app_with_fake_gitea):
|
||||||
|
# INV-3 regression: a migrated (body-only .md) entry whose sidecar is
|
||||||
|
# corrupt must NOT vanish from the catalog — it loads (slug from the
|
||||||
|
# filename stem) and is flagged malformed.
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/orphaned.md")] = {
|
||||||
|
"content": "Just the body, no frontmatter.\n", "sha": "o1"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/orphaned.meta.yaml")] = {
|
||||||
|
"content": "- corrupt\n- list\n", "sha": "o2"}
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
row = _row("orphaned")
|
||||||
|
assert row is not None # did not vanish
|
||||||
|
assert row["metadata_malformed"] == 1
|
||||||
|
assert row["body"].strip() == "Just the body, no frontmatter."
|
||||||
|
|
||||||
|
|
||||||
|
def _set_default_fields_schema(schema):
|
||||||
|
# apply_registry leaves the default collection's config_json untouched, so a
|
||||||
|
# schema set here survives a corpus refresh (refresh_meta_repo only).
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||||
|
(json.dumps({"fields": schema}),))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- §22.4a SLICE-2: advisory schema validation at ingest (INV-3) ----
|
||||||
|
|
||||||
|
def test_schema_violation_flags_malformed(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
_set_default_fields_schema(
|
||||||
|
{"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-prio.md")] = {
|
||||||
|
"content": "---\nslug: bad-prio\ntitle: Bad\nstate: active\npriority: P9\n---\n\nB.\n",
|
||||||
|
"sha": "bp1"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/good-prio.md")] = {
|
||||||
|
"content": "---\nslug: good-prio\ntitle: Good\nstate: active\npriority: P0\n---\n\nB.\n",
|
||||||
|
"sha": "gp1"}
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
assert _row("bad-prio")["metadata_malformed"] == 1 # INV-3: flagged
|
||||||
|
assert _row("bad-prio")["title"] == "Bad" # still loads
|
||||||
|
assert _row("good-prio")["metadata_malformed"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_ignores_undeclared_keys(app_with_fake_gitea):
|
||||||
|
# INV-7: keys the schema doesn't declare ride along and never flag malformed.
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
_set_default_fields_schema(
|
||||||
|
{"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/extra-key.md")] = {
|
||||||
|
"content": "---\nslug: extra-key\ntitle: Extra\nstate: active\nowner: hasan\n---\n\nB.\n",
|
||||||
|
"sha": "ek1"}
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
assert _row("extra-key")["metadata_malformed"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_schema_never_flags(app_with_fake_gitea):
|
||||||
|
# INV-5: a collection with no field schema validates nothing, even when an
|
||||||
|
# entry carries values that would fail a schema if one existed.
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/anything.md")] = {
|
||||||
|
"content": "---\nslug: anything\ntitle: Any\nstate: active\npriority: whatever\n---\n\nB.\n",
|
||||||
|
"sha": "an1"}
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
assert _row("anything")["metadata_malformed"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_collection_without_sidecars_unchanged(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app):
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/legacy.md")] = {
|
||||||
|
"content": "---\nslug: legacy\ntitle: Legacy\nstate: super-draft\n---\n\nPitch.\n",
|
||||||
|
"sha": "l1"}
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
row = _row("legacy")
|
||||||
|
assert row is not None
|
||||||
|
assert row["title"] == "Legacy"
|
||||||
|
assert row["state"] == "super-draft"
|
||||||
|
assert row["body"].strip() == "Pitch."
|
||||||
|
assert row["metadata_malformed"] == 0
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""SLICE-4 — single-entry metadata edit endpoint (PUC-1, §6.4).
|
||||||
|
|
||||||
|
Through the real API: contributor+ gating (INV-4), schema validation at the
|
||||||
|
write boundary, direct commit to the sidecar with lazy migration, re-ingest,
|
||||||
|
and the GET RFC `meta` + `can_edit_meta` exposure. Reuses the fake-Gitea harness.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import cache, db, gitea as gitea_mod
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
PID = "default"
|
||||||
|
CID = "default"
|
||||||
|
META = f"/api/projects/{PID}/collections/{CID}"
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh():
|
||||||
|
cfg = load_config()
|
||||||
|
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_fields(schema):
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||||
|
(json.dumps({"fields": schema}),))
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_legacy(fake, slug, *, state="active", **front):
|
||||||
|
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
|
||||||
|
body = yaml.safe_dump(fm, sort_keys=False).strip()
|
||||||
|
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||||
|
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_migrated(fake, slug, sidecar):
|
||||||
|
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||||
|
"content": "Body.\n", "sha": f"{slug}-md"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")] = {
|
||||||
|
"content": sidecar, "sha": f"{slug}-sc"}
|
||||||
|
|
||||||
|
|
||||||
|
def _login_owner(client):
|
||||||
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_meta_sets_value_commits_sidecar_and_lazy_migrates(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||||
|
"tags": {"type": "tags"}})
|
||||||
|
_seed_legacy(fake, "a", priority="P1", tags=["x"])
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["meta"]["priority"] == "P0"
|
||||||
|
# sidecar written, .md lazy-migrated to body-only
|
||||||
|
sc = yaml.safe_load(
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/a.meta.yaml")]["content"])
|
||||||
|
assert sc["priority"] == "P0"
|
||||||
|
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")]["content"]
|
||||||
|
# cache reflects the new value
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT meta_json FROM cached_rfcs WHERE slug='a'").fetchone()
|
||||||
|
assert json.loads(row["meta_json"])["priority"] == "P0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_meta_rejects_value_outside_enum(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "ZZZ"}})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
# nothing committed
|
||||||
|
assert ("wiggleverse", "meta", "main", "rfcs/a.meta.yaml") not in fake.files
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_meta_rejects_unknown_field(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"nope": "x"}})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_meta_forbidden_for_anonymous(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}})
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_meta_on_already_migrated_entry(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
_seed_migrated(fake, "a", "slug: a\ntitle: A\nstate: active\npriority: P1\n")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
sc = yaml.safe_load(
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/a.meta.yaml")]["content"])
|
||||||
|
assert sc["priority"] == "P0"
|
||||||
|
# .md untouched (still body-only)
|
||||||
|
assert fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")]["content"] == "Body.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_rfc_exposes_meta_and_can_edit(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
# anonymous: meta present, can_edit_meta False
|
||||||
|
r = client.get(f"{META}/rfcs/a")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["meta"]["priority"] == "P1"
|
||||||
|
assert r.json()["can_edit_meta"] is False
|
||||||
|
# owner: can_edit_meta True
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.get(f"{META}/rfcs/a")
|
||||||
|
assert r.json()["can_edit_meta"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Owner-gated collection migrate endpoint (PUC-5) ----
|
||||||
|
|
||||||
|
def test_migrate_collection_endpoint_owner(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_seed_legacy(fake, "a", priority="P1", tags=["x"])
|
||||||
|
_seed_legacy(fake, "b", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{META}/migrate")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["committed"] is True
|
||||||
|
assert set(r.json()["migrated"]) == {"a", "b"}
|
||||||
|
# both entries now body-only + sidecar
|
||||||
|
for slug in ("a", "b"):
|
||||||
|
assert "---" not in fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")]["content"]
|
||||||
|
assert ("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml") in fake.files
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_collection_forbidden_for_contributor(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_seed_legacy(fake, "a", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||||
|
display_name="Carol", role="contributor")
|
||||||
|
r = client.post(f"{META}/migrate")
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_collection_idempotent(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_seed_legacy(fake, "a", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
assert client.post(f"{META}/migrate").json()["committed"] is True
|
||||||
|
# second run: nothing left to migrate
|
||||||
|
r2 = client.post(f"{META}/migrate")
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["committed"] is False
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""SLICE-4 — git-aware sidecar read/write helpers.
|
||||||
|
|
||||||
|
Uses the FakeGitea from the propose-vertical fixtures (no network).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app import gitea as gitea_mod, metadata
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||||
|
|
||||||
|
LEGACY = """---
|
||||||
|
slug: alpha
|
||||||
|
title: Alpha
|
||||||
|
state: active
|
||||||
|
owners:
|
||||||
|
- ben.stull
|
||||||
|
tags:
|
||||||
|
- one
|
||||||
|
priority: P1
|
||||||
|
---
|
||||||
|
|
||||||
|
Alpha body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MIGRATED_MD = "Alpha body.\n"
|
||||||
|
MIGRATED_SIDECAR = """slug: alpha
|
||||||
|
title: Alpha
|
||||||
|
state: active
|
||||||
|
owners:
|
||||||
|
- ben.stull
|
||||||
|
tags:
|
||||||
|
- one
|
||||||
|
priority: P1
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _gitea():
|
||||||
|
return gitea_mod.Gitea(load_config())
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_entry_from_git_legacy(app_with_fake_gitea):
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": LEGACY, "sha": "s1"}
|
||||||
|
st = asyncio.run(metadata.read_entry_from_git(
|
||||||
|
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||||
|
assert st is not None
|
||||||
|
assert st.entry.slug == "alpha"
|
||||||
|
assert st.entry.extra["priority"] == "P1"
|
||||||
|
assert st.sidecar_sha is None # no sidecar yet
|
||||||
|
assert st.malformed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_entry_from_git_migrated(app_with_fake_gitea):
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": MIGRATED_MD, "sha": "s1"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||||
|
"content": MIGRATED_SIDECAR, "sha": "s2"}
|
||||||
|
st = asyncio.run(metadata.read_entry_from_git(
|
||||||
|
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||||
|
assert st.entry.extra["priority"] == "P1" # from sidecar
|
||||||
|
assert st.entry.body == "Alpha body.\n"
|
||||||
|
assert st.sidecar_sha == "s2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_entry_from_git_missing(app_with_fake_gitea):
|
||||||
|
st = asyncio.run(metadata.read_entry_from_git(
|
||||||
|
_gitea(), "wiggleverse", "meta", "rfcs/nope.md"))
|
||||||
|
assert st is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_entry_files_lazy_migrates_legacy(app_with_fake_gitea):
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": LEGACY, "sha": "s1"}
|
||||||
|
st = asyncio.run(metadata.read_entry_from_git(
|
||||||
|
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||||
|
e2 = metadata.apply_values(st.entry, {"priority": "P0"})
|
||||||
|
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||||
|
paths = {o["path"]: o for o in ops}
|
||||||
|
# sidecar created, .md rewritten body-only
|
||||||
|
assert "rfcs/alpha.meta.yaml" in paths
|
||||||
|
assert paths["rfcs/alpha.meta.yaml"]["operation"] == "create"
|
||||||
|
assert paths["rfcs/alpha.md"]["operation"] == "update"
|
||||||
|
assert "---" not in paths["rfcs/alpha.md"]["content"] # INV-2 clean body
|
||||||
|
assert yaml.safe_load(paths["rfcs/alpha.meta.yaml"]["content"])["priority"] == "P0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_entry_files_already_migrated_touches_sidecar_only(app_with_fake_gitea):
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": MIGRATED_MD, "sha": "s1"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||||
|
"content": MIGRATED_SIDECAR, "sha": "s2"}
|
||||||
|
st = asyncio.run(metadata.read_entry_from_git(
|
||||||
|
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||||
|
e2 = metadata.apply_values(st.entry, {"priority": "P0"})
|
||||||
|
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||||
|
paths = {o["path"]: o for o in ops}
|
||||||
|
assert set(paths) == {"rfcs/alpha.meta.yaml"} # .md untouched
|
||||||
|
assert paths["rfcs/alpha.meta.yaml"]["operation"] == "update"
|
||||||
|
assert paths["rfcs/alpha.meta.yaml"]["sha"] == "s2"
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""SLICE-1 integration — frontmatter→sidecar migration tool (PUC-5).
|
||||||
|
|
||||||
|
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.5 / §7.2:
|
||||||
|
a tool walks a collection; for each entry with legacy frontmatter it writes
|
||||||
|
`<slug>.meta.yaml` and rewrites `<slug>.md` to the body only — one commit per
|
||||||
|
collection, idempotent, preserving unknown keys (INV-7).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app import gitea as gitea_mod, metadata
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||||
|
app_with_fake_gitea,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ALPHA_MD = """---
|
||||||
|
slug: alpha
|
||||||
|
title: Alpha
|
||||||
|
state: active
|
||||||
|
owners:
|
||||||
|
- ben.stull
|
||||||
|
tags:
|
||||||
|
- one
|
||||||
|
priority: P1
|
||||||
|
---
|
||||||
|
|
||||||
|
Alpha body prose.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BETA_MD = """---
|
||||||
|
slug: beta
|
||||||
|
title: Beta
|
||||||
|
state: super-draft
|
||||||
|
---
|
||||||
|
|
||||||
|
Beta body prose.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_entries(fake):
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": ALPHA_MD, "sha": "a0001"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
|
||||||
|
"content": BETA_MD, "sha": "b0001"}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_migration(subfolder=""):
|
||||||
|
cfg = load_config()
|
||||||
|
gitea = gitea_mod.Gitea(cfg)
|
||||||
|
return asyncio.run(
|
||||||
|
metadata.migrate_collection(
|
||||||
|
gitea, org="wiggleverse", repo="meta", subfolder=subfolder
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_writes_sidecars_and_strips_bodies(app_with_fake_gitea):
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
_seed_entries(fake)
|
||||||
|
|
||||||
|
summary = _run_migration()
|
||||||
|
|
||||||
|
assert sorted(summary["migrated"]) == ["alpha", "beta"]
|
||||||
|
|
||||||
|
# Sidecars now exist.
|
||||||
|
alpha_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||||
|
beta_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/beta.meta.yaml")]["content"]
|
||||||
|
alpha_vals = yaml.safe_load(alpha_sc)
|
||||||
|
assert alpha_vals["slug"] == "alpha"
|
||||||
|
assert alpha_vals["title"] == "Alpha"
|
||||||
|
assert alpha_vals["tags"] == ["one"]
|
||||||
|
# INV-7: the unknown key rides along into the sidecar.
|
||||||
|
assert alpha_vals["priority"] == "P1"
|
||||||
|
|
||||||
|
# .md bodies are stripped of frontmatter (INV-2).
|
||||||
|
alpha_md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||||
|
assert "---" not in alpha_md
|
||||||
|
assert "priority:" not in alpha_md
|
||||||
|
assert alpha_md.strip() == "Alpha body prose."
|
||||||
|
assert beta_sc # beta got a sidecar too
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_is_idempotent(app_with_fake_gitea):
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
_seed_entries(fake)
|
||||||
|
|
||||||
|
first = _run_migration()
|
||||||
|
assert sorted(first["migrated"]) == ["alpha", "beta"]
|
||||||
|
assert first["committed"] is True
|
||||||
|
|
||||||
|
commits_after_first = fake._commit_counter
|
||||||
|
alpha_md_after_first = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||||
|
|
||||||
|
second = _run_migration()
|
||||||
|
assert second["migrated"] == []
|
||||||
|
assert second["committed"] is False
|
||||||
|
# No new commit; files untouched.
|
||||||
|
assert fake._commit_counter == commits_after_first
|
||||||
|
assert fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] == alpha_md_after_first
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_one_commit_for_whole_collection(app_with_fake_gitea):
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
_seed_entries(fake)
|
||||||
|
before = fake._commit_counter
|
||||||
|
_run_migration()
|
||||||
|
# Two entries migrated in exactly one commit (ChangeFiles batch).
|
||||||
|
assert fake._commit_counter == before + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_dual_read_equivalence_after_migrate(app_with_fake_gitea):
|
||||||
|
"""An entry reads identically before and after migration (INV-6)."""
|
||||||
|
_app, fake = app_with_fake_gitea
|
||||||
|
_seed_entries(fake)
|
||||||
|
|
||||||
|
before, _ = metadata.read_entry(ALPHA_MD, None)
|
||||||
|
_run_migration()
|
||||||
|
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||||
|
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||||
|
after, malformed = metadata.read_entry(md, sc)
|
||||||
|
|
||||||
|
assert malformed is False
|
||||||
|
assert after.slug == before.slug
|
||||||
|
assert after.title == before.title
|
||||||
|
assert after.state == before.state
|
||||||
|
assert after.tags == before.tags
|
||||||
|
assert after.extra == before.extra
|
||||||
|
assert after.body.strip() == before.body.strip()
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""SLICE-2 unit tests — collection field schema + central validation.
|
||||||
|
|
||||||
|
Pure functions only (no DB / no Gitea). Per
|
||||||
|
docs/design/2026-06-06-configurable-collection-metadata.md §7.2 (SLICE-2):
|
||||||
|
`metadata_schema.parse_fields` (lenient schema parsing) and
|
||||||
|
`metadata_schema.validate` (advisory at read / enforcement point at write).
|
||||||
|
Honors INV-3 (never hard-fails), INV-5 (no-fields unchanged), INV-7 (undeclared
|
||||||
|
keys ride along).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app import metadata_schema as ms
|
||||||
|
|
||||||
|
|
||||||
|
# ---- parse_fields: normalization + leniency ----
|
||||||
|
|
||||||
|
def test_parse_fields_each_type():
|
||||||
|
raw = {
|
||||||
|
"priority": {"type": "enum", "values": ["P0", "P1", "P2"], "label": "Priority"},
|
||||||
|
"tags": {"type": "tags"},
|
||||||
|
"owner": {"type": "text"},
|
||||||
|
}
|
||||||
|
fields = ms.parse_fields(raw)
|
||||||
|
assert list(fields) == ["priority", "tags", "owner"] # order preserved
|
||||||
|
assert fields["priority"] == {
|
||||||
|
"type": "enum",
|
||||||
|
"values": ["P0", "P1", "P2"],
|
||||||
|
"label": "Priority",
|
||||||
|
}
|
||||||
|
assert fields["tags"] == {"type": "tags"}
|
||||||
|
assert fields["owner"] == {"type": "text"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fields_controlled_tags_keeps_values():
|
||||||
|
fields = ms.parse_fields({"area": {"type": "tags", "values": ["a", "b"]}})
|
||||||
|
assert fields["area"] == {"type": "tags", "values": ["a", "b"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fields_missing_block_is_empty():
|
||||||
|
assert ms.parse_fields(None) == {}
|
||||||
|
assert ms.parse_fields({}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fields_non_mapping_block_skipped():
|
||||||
|
assert ms.parse_fields(["not", "a", "mapping"]) == {}
|
||||||
|
assert ms.parse_fields("nope") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fields_enum_without_values_skipped():
|
||||||
|
# enum requires a non-empty values list — skipped, not fatal.
|
||||||
|
assert ms.parse_fields({"p": {"type": "enum"}}) == {}
|
||||||
|
assert ms.parse_fields({"p": {"type": "enum", "values": []}}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fields_unknown_type_skipped():
|
||||||
|
fields = ms.parse_fields(
|
||||||
|
{"good": {"type": "text"}, "bad": {"type": "ref"}, "huh": {"type": "frob"}}
|
||||||
|
)
|
||||||
|
assert list(fields) == ["good"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fields_non_mapping_def_skipped():
|
||||||
|
fields = ms.parse_fields({"good": {"type": "text"}, "bad": "scalar"})
|
||||||
|
assert list(fields) == ["good"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_fields_values_coerced_to_str_list():
|
||||||
|
fields = ms.parse_fields({"p": {"type": "enum", "values": [0, 1, 2]}})
|
||||||
|
assert fields["p"]["values"] == ["0", "1", "2"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- validate: advisory problem reporting ----
|
||||||
|
|
||||||
|
SCHEMA = ms.parse_fields(
|
||||||
|
{
|
||||||
|
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||||
|
"tags": {"type": "tags"},
|
||||||
|
"area": {"type": "tags", "values": ["checkout", "cart"]},
|
||||||
|
"owner": {"type": "text"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_happy():
|
||||||
|
values = {
|
||||||
|
"priority": "P0",
|
||||||
|
"tags": ["anything", "free"],
|
||||||
|
"area": ["checkout"],
|
||||||
|
"owner": "ben",
|
||||||
|
}
|
||||||
|
assert ms.validate(values, SCHEMA) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_absent_fields_ok():
|
||||||
|
# A declared field that the entry omits is fine (no required fields in v1).
|
||||||
|
assert ms.validate({}, SCHEMA) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_enum_bad_value():
|
||||||
|
problems = ms.validate({"priority": "P9"}, SCHEMA)
|
||||||
|
assert [p.field for p in problems] == ["priority"]
|
||||||
|
assert problems[0].code == "not-in-values"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_enum_wrong_type():
|
||||||
|
problems = ms.validate({"priority": ["P0"]}, SCHEMA)
|
||||||
|
assert problems[0].field == "priority"
|
||||||
|
assert problems[0].code == "wrong-type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_tags_free_form_ok():
|
||||||
|
assert ms.validate({"tags": ["x", "y", "z"]}, SCHEMA) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_tags_wrong_type():
|
||||||
|
problems = ms.validate({"tags": "notalist"}, SCHEMA)
|
||||||
|
assert problems[0].field == "tags"
|
||||||
|
assert problems[0].code == "wrong-type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_controlled_tags_bad_member():
|
||||||
|
problems = ms.validate({"area": ["checkout", "nope"]}, SCHEMA)
|
||||||
|
assert problems[0].field == "area"
|
||||||
|
assert problems[0].code == "not-in-values"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_text_wrong_type():
|
||||||
|
problems = ms.validate({"owner": ["a", "b"]}, SCHEMA)
|
||||||
|
assert problems[0].field == "owner"
|
||||||
|
assert problems[0].code == "wrong-type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_undeclared_keys_ignored():
|
||||||
|
# INV-7: keys outside the schema ride along untouched, never flagged.
|
||||||
|
assert ms.validate({"random": "value", "slug": "x", "title": "y"}, SCHEMA) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_empty_schema_no_problems():
|
||||||
|
# INV-5: a collection with no fields validates everything as clean.
|
||||||
|
assert ms.validate({"priority": "anything", "x": 1}, {}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_problem_as_dict():
|
||||||
|
p = ms.Problem(field="priority", code="not-in-values", message="bad")
|
||||||
|
assert p.as_dict() == {
|
||||||
|
"field": "priority",
|
||||||
|
"code": "not-in-values",
|
||||||
|
"message": "bad",
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""SLICE-4 — write paths are sidecar-aware: a migrated (body-only `.md` +
|
||||||
|
sidecar) entry never crashes `entry.parse` nor re-grows frontmatter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app import gitea as gitea_mod, metadata
|
||||||
|
from app.bot import Actor, Bot
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
BODY_ONLY = "Alpha prose body.\n"
|
||||||
|
SIDECAR = ("slug: alpha\ntitle: Alpha\nstate: active\n"
|
||||||
|
"owners:\n- ben.stull\ntags:\n- one\npriority: P1\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_migrated(fake, repo="meta"):
|
||||||
|
fake.files[("wiggleverse", repo, "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": BODY_ONLY, "sha": "m1"}
|
||||||
|
fake.files[("wiggleverse", repo, "main", "rfcs/alpha.meta.yaml")] = {
|
||||||
|
"content": SIDECAR, "sha": "m2"}
|
||||||
|
|
||||||
|
|
||||||
|
def _actor():
|
||||||
|
return Actor(user_id=1, gitea_login="ben.stull", display_name="Ben", email="ben@x.io")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_entry_reviewed_on_migrated_entry(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
_seed_migrated(fake)
|
||||||
|
gitea = gitea_mod.Gitea(load_config())
|
||||||
|
bot = Bot(gitea)
|
||||||
|
with TestClient(app):
|
||||||
|
provision_user_row(user_id=1, login="ben.stull", role="owner")
|
||||||
|
# Must not raise (legacy code parsed body-only .md → ValueError).
|
||||||
|
asyncio.run(bot.mark_entry_reviewed(
|
||||||
|
_actor(), org="wiggleverse", meta_repo="meta", slug="alpha",
|
||||||
|
reviewed_by="ben.stull", reviewed_at="2026-06-07"))
|
||||||
|
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||||
|
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||||
|
assert "---" not in md # body stays clean (no re-grown FM)
|
||||||
|
assert "reviewed_by: ben.stull" in sc # review stamp landed in the sidecar
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Task 3.2: body extract/wrap helpers (api_branches) ----
|
||||||
|
|
||||||
|
def test_extract_wrap_body_on_body_only_md():
|
||||||
|
from app import api_branches
|
||||||
|
rfc = {"state": "super-draft", "repo": None, "slug": "alpha", "collection_id": "default"}
|
||||||
|
body = api_branches._extract_body_pure(rfc, BODY_ONLY, "main", is_meta=True)
|
||||||
|
assert body == BODY_ONLY
|
||||||
|
wrapped = api_branches._wrap_body_pure(rfc, BODY_ONLY, "new body\n", "main", is_meta=True)
|
||||||
|
assert wrapped == "new body\n" # stays clean — no re-grown frontmatter
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_body_preserves_legacy_frontmatter():
|
||||||
|
from app import api_branches
|
||||||
|
legacy = "---\nslug: alpha\ntitle: Alpha\nstate: active\n---\n\nold body\n"
|
||||||
|
rfc = {"state": "super-draft", "repo": None, "slug": "alpha", "collection_id": "default"}
|
||||||
|
wrapped = api_branches._wrap_body_pure(rfc, legacy, "new body\n", "main", is_meta=True)
|
||||||
|
assert wrapped.startswith("---") # legacy frontmatter preserved
|
||||||
|
assert "new body" in wrapped
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Task 3.3: PR-replay body wrappers (api_prs) ----
|
||||||
|
|
||||||
|
def test_replay_wrappers_on_body_only():
|
||||||
|
from app import api_prs
|
||||||
|
assert api_prs._extract_body_for_replay(True, BODY_ONLY) == BODY_ONLY
|
||||||
|
out = api_prs._wrap_body_for_replay(True, BODY_ONLY, "new\n")
|
||||||
|
assert out == "new\n" # clean, no re-grown frontmatter
|
||||||
|
legacy = "---\nslug: a\ntitle: A\nstate: active\n---\n\nold\n"
|
||||||
|
out2 = api_prs._wrap_body_for_replay(True, legacy, "new\n")
|
||||||
|
assert out2.startswith("---") # legacy preserved
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Task 3.4: retire a fully-migrated (body-only + sidecar) entry ----
|
||||||
|
|
||||||
|
def test_retire_already_migrated_entry_does_not_crash(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import cache, db
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
_seed_migrated(fake)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
|
from test_propose_vertical import sign_in_as
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||||
|
# Ingest the migrated entry so the catalog/cache knows it.
|
||||||
|
asyncio.run(cache.refresh_meta_repo(load_config(), gitea_mod.Gitea(load_config())))
|
||||||
|
assert db.conn().execute(
|
||||||
|
"SELECT state FROM cached_rfcs WHERE slug='alpha'").fetchone()["state"] == "active"
|
||||||
|
r = client.post("/api/rfcs/alpha/retire")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["state"] == "retired"
|
||||||
|
import yaml as _yaml
|
||||||
|
sc = _yaml.safe_load(
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"])
|
||||||
|
assert sc["state"] == "retired"
|
||||||
|
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||||
|
assert "---" not in md # body stayed clean
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Task 3.5: graduate a fully-migrated super-draft entry ----
|
||||||
|
|
||||||
|
SUPER_SIDECAR = ("slug: alpha\ntitle: Alpha\nstate: super-draft\n"
|
||||||
|
"owners:\n- ben\ntags:\n- one\npriority: P1\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_graduate_already_migrated_super_draft(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import cache, db
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||||
|
"content": BODY_ONLY, "sha": "m1"}
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||||
|
"content": SUPER_SIDECAR, "sha": "m2"}
|
||||||
|
with TestClient(app) as client:
|
||||||
|
from test_propose_vertical import sign_in_as
|
||||||
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||||
|
asyncio.run(cache.refresh_meta_repo(load_config(), gitea_mod.Gitea(load_config())))
|
||||||
|
assert db.conn().execute(
|
||||||
|
"SELECT state FROM cached_rfcs WHERE slug='alpha'").fetchone()["state"] == "super-draft"
|
||||||
|
r = client.post("/api/rfcs/alpha/graduate?_sync=1",
|
||||||
|
json={"rfc_id": "RFC-0007", "owners": ["ben"]})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
import yaml as _yaml
|
||||||
|
sc = _yaml.safe_load(
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"])
|
||||||
|
assert sc["state"] == "active"
|
||||||
|
assert sc["id"] == "RFC-0007"
|
||||||
|
assert sc.get("priority") == "P1" # INV-7 carried through graduation
|
||||||
|
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||||
|
assert "---" not in md # body stayed clean
|
||||||
@@ -54,6 +54,9 @@ class FakeGitea:
|
|||||||
self.repos: set[tuple[str, str]] = set()
|
self.repos: set[tuple[str, str]] = set()
|
||||||
self._pr_counter = 0
|
self._pr_counter = 0
|
||||||
self._commit_counter = 0
|
self._commit_counter = 0
|
||||||
|
# count of batch ChangeFiles commits (one per /contents POST with a
|
||||||
|
# files[] array) — lets tests assert "N files, one commit" (§22.4a D7).
|
||||||
|
self.change_files_calls = 0
|
||||||
self._seed_repo("wiggleverse", "meta")
|
self._seed_repo("wiggleverse", "meta")
|
||||||
# §22 M3: the deployment's project registry. Startup refresh_registry
|
# §22 M3: the deployment's project registry. Startup refresh_registry
|
||||||
# reads projects.yaml here; the single 'default' project's content_repo
|
# reads projects.yaml here; the single 'default' project's content_repo
|
||||||
@@ -280,6 +283,29 @@ class FakeGitea:
|
|||||||
return httpx.Response(200, json=children)
|
return httpx.Response(200, json=children)
|
||||||
return httpx.Response(404, json={"message": "not found"})
|
return httpx.Response(404, json={"message": "not found"})
|
||||||
|
|
||||||
|
# POST /repos/{owner}/{repo}/contents — ChangeFiles (batch, one commit).
|
||||||
|
# §22.4a SLICE-1: the frontmatter→sidecar migration writes N files in a
|
||||||
|
# single commit. Matches the no-path /contents route (the per-path POST
|
||||||
|
# below needs a /contents/<path> suffix).
|
||||||
|
m_batch = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path)
|
||||||
|
if method == "POST" and m_batch:
|
||||||
|
owner, repo = m_batch.groups()
|
||||||
|
branch = payload["branch"]
|
||||||
|
self.change_files_calls += 1
|
||||||
|
sha = self._next_sha()
|
||||||
|
for f in payload["files"]:
|
||||||
|
op = f["operation"]
|
||||||
|
fpath = f["path"]
|
||||||
|
if op == "delete":
|
||||||
|
self.files.pop((owner, repo, branch, fpath), None)
|
||||||
|
else:
|
||||||
|
content = base64.b64decode(f["content"]).decode()
|
||||||
|
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
|
||||||
|
br = self.branches[(owner, repo)].setdefault(branch, {})
|
||||||
|
br["sha"] = sha
|
||||||
|
br["ts"] = "2026-05-23T00:00:00Z"
|
||||||
|
return httpx.Response(201, json={"commit": {"sha": sha}})
|
||||||
|
|
||||||
# POST /repos/{owner}/{repo}/contents/{path}
|
# POST /repos/{owner}/{repo}/contents/{path}
|
||||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
||||||
if method == "POST" and m:
|
if method == "POST" and m:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""The per-IP limiter budgets are env-overridable (test/PPE stacks drive the
|
||||||
|
auth endpoints repeatedly from one IP); production leaves them unset and keeps
|
||||||
|
the secure defaults. A non-positive / unparseable value falls back."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_with(monkeypatch, **env):
|
||||||
|
for k, v in env.items():
|
||||||
|
if v is None:
|
||||||
|
monkeypatch.delenv(k, raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv(k, v)
|
||||||
|
import app.ratelimit as ratelimit
|
||||||
|
return importlib.reload(ratelimit)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore():
|
||||||
|
yield
|
||||||
|
# Leave the module in its default state for other tests.
|
||||||
|
import app.ratelimit as ratelimit
|
||||||
|
importlib.reload(ratelimit)
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_when_unset(monkeypatch):
|
||||||
|
rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX=None, RATELIMIT_VERIFY_MAX=None)
|
||||||
|
assert rl.otc_request_limiter.max_events == 5
|
||||||
|
assert rl.verify_limiter.max_events == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_override(monkeypatch):
|
||||||
|
rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX="1000", RATELIMIT_VERIFY_MAX="250")
|
||||||
|
assert rl.otc_request_limiter.max_events == 1000
|
||||||
|
assert rl.verify_limiter.max_events == 250
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_value_falls_back_to_default(monkeypatch):
|
||||||
|
rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX="nope", RATELIMIT_VERIFY_MAX="0")
|
||||||
|
assert rl.otc_request_limiter.max_events == 5 # unparseable → default
|
||||||
|
assert rl.verify_limiter.max_events == 10 # non-positive → default
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""§22 framework bug — migration-029 vs registry-mirror collection-id
|
||||||
|
divergence for a multi-project deployment's default project.
|
||||||
|
|
||||||
|
Migration 029 seeds the default project's collection id as the literal
|
||||||
|
'default' only when one project exists at migration time; with ≥2 projects it
|
||||||
|
falls back to the *project id*. The registry mirror expects the default
|
||||||
|
project to own the collection id 'default'. On an upgrade whose DB already
|
||||||
|
held ≥2 projects when 029 ran, the default project's collection is therefore
|
||||||
|
named after the project (e.g. 'ohm'), and the next mirror would INSERT a
|
||||||
|
second, empty 'default' collection. `reconcile_default_collection_id` heals
|
||||||
|
the divergence at startup, before the mirror, so the mirror merges instead of
|
||||||
|
duplicating. This is the collection-grain twin of `restamp_default_project`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import app.db as db
|
||||||
|
from app import projects, registry
|
||||||
|
|
||||||
|
_TWO_PROJECT_REGISTRY = """
|
||||||
|
deployment:
|
||||||
|
name: Open Human Model
|
||||||
|
tagline: t
|
||||||
|
projects:
|
||||||
|
- id: ohm
|
||||||
|
name: Open Human Model
|
||||||
|
type: document
|
||||||
|
content_repo: ohm-content
|
||||||
|
visibility: public
|
||||||
|
- id: ecomm
|
||||||
|
name: Ecomm
|
||||||
|
type: bdd
|
||||||
|
content_repo: ecomm-content
|
||||||
|
visibility: public
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _collection_ids_for(conn, project_id):
|
||||||
|
return {r["id"] for r in conn.execute(
|
||||||
|
"SELECT id FROM collections WHERE project_id = ?", (project_id,))}
|
||||||
|
|
||||||
|
|
||||||
|
class _Cfg:
|
||||||
|
def __init__(self, path, default_id):
|
||||||
|
self.database_path = path
|
||||||
|
self.default_project_id = default_id
|
||||||
|
|
||||||
|
|
||||||
|
def _divergent_multiproject(monkeypatch, default_id="ohm"):
|
||||||
|
"""A DB in the post-029 divergent state: the default project ('ohm') owns a
|
||||||
|
collection whose id is the project id (the 029 ≥2-projects seed), a second
|
||||||
|
project ('ecomm') owns its own collection, and NO 'default' collection
|
||||||
|
exists. Entry rows point at the divergent collection_id='ohm'."""
|
||||||
|
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||||
|
cfg = _Cfg(path, default_id)
|
||||||
|
db.run_migrations(cfg) # seeds bootstrap 'default' project + 'default' collection
|
||||||
|
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||||
|
conn = db.conn()
|
||||||
|
# Drop the single-project bootstrap seed and rebuild the divergent
|
||||||
|
# multi-project state 029 would have produced on an upgrade.
|
||||||
|
conn.execute("DELETE FROM collections")
|
||||||
|
conn.execute("DELETE FROM projects")
|
||||||
|
conn.execute("INSERT INTO projects (id,name,content_repo,visibility) "
|
||||||
|
"VALUES ('ohm','Open Human Model','ohm-content','public')")
|
||||||
|
conn.execute("INSERT INTO projects (id,name,content_repo,visibility) "
|
||||||
|
"VALUES ('ecomm','Ecomm','ecomm-content','public')")
|
||||||
|
# 029 ≥2-projects seed: collection id == project id.
|
||||||
|
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
||||||
|
"VALUES ('ohm','ohm','document','','super-draft','public','Open Human Model')")
|
||||||
|
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')")
|
||||||
|
# Entry data for the default project lives in the divergent 'ohm' collection.
|
||||||
|
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('human','Human','active','ohm')")
|
||||||
|
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,collection_id) "
|
||||||
|
"VALUES ('human',1,'contributor','ohm')")
|
||||||
|
conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,'human','ohm')")
|
||||||
|
# The 'ecomm' collection has its own entry.
|
||||||
|
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('cart','Cart','active','ecomm')")
|
||||||
|
return cfg, conn
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_renames_divergent_default_collection_to_default(monkeypatch):
|
||||||
|
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||||
|
projects.reconcile_default_collection_id(cfg)
|
||||||
|
# The default project's collection id is now the canonical 'default'.
|
||||||
|
assert conn.execute("SELECT 1 FROM collections WHERE id='ohm'").fetchone() is None
|
||||||
|
row = conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()
|
||||||
|
assert row is not None and row["project_id"] == "ohm"
|
||||||
|
# Entry rows cascaded onto 'default'.
|
||||||
|
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default"
|
||||||
|
assert conn.execute("SELECT collection_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["collection_id"] == "default"
|
||||||
|
assert conn.execute("SELECT collection_id FROM stars WHERE rfc_slug='human'").fetchone()["collection_id"] == "default"
|
||||||
|
# The non-default 'ecomm' collection is untouched (mirror + 029 agree on it).
|
||||||
|
assert conn.execute("SELECT 1 FROM collections WHERE id='ecomm'").fetchone() is not None
|
||||||
|
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='cart'").fetchone()["collection_id"] == "ecomm"
|
||||||
|
# FK integrity intact after the rename.
|
||||||
|
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_is_idempotent(monkeypatch):
|
||||||
|
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||||
|
projects.reconcile_default_collection_id(cfg)
|
||||||
|
projects.reconcile_default_collection_id(cfg) # second call: already aligned → no-op
|
||||||
|
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
|
||||||
|
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE collection_id='default'").fetchone()["c"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_noop_when_default_id_is_literal_default(monkeypatch):
|
||||||
|
# Single-project deployment, no DEFAULT_PROJECT_ID: 029 already seeded
|
||||||
|
# 'default' and the mirror agrees — nothing to reconcile.
|
||||||
|
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||||
|
cfg = _Cfg(path, "") # resolves to 'default'
|
||||||
|
db.run_migrations(cfg)
|
||||||
|
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||||
|
conn = db.conn()
|
||||||
|
projects.reconcile_default_collection_id(cfg)
|
||||||
|
assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mirror_duplicates_without_reconcile(monkeypatch):
|
||||||
|
# Demonstrates the bug: the mirror on the divergent state inserts a SECOND
|
||||||
|
# 'default' collection for the default project (alongside the 029 'ohm').
|
||||||
|
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||||
|
doc = registry.parse_registry(_TWO_PROJECT_REGISTRY)
|
||||||
|
registry.apply_registry(doc, registry_sha="s1", default_id="ohm")
|
||||||
|
assert _collection_ids_for(conn, "ohm") == {"ohm", "default"} # duplicate!
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_then_mirror_merges_no_duplicate(monkeypatch):
|
||||||
|
# With the fix: reconcile before the mirror → the mirror merges onto the
|
||||||
|
# canonical 'default' collection; the default project owns exactly one.
|
||||||
|
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||||
|
projects.reconcile_default_collection_id(cfg)
|
||||||
|
doc = registry.parse_registry(_TWO_PROJECT_REGISTRY)
|
||||||
|
registry.apply_registry(doc, registry_sha="s1", default_id="ohm")
|
||||||
|
assert _collection_ids_for(conn, "ohm") == {"default"}
|
||||||
|
assert _collection_ids_for(conn, "ecomm") == {"ecomm"}
|
||||||
|
# The default project's corpus entry is intact under 'default'.
|
||||||
|
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default"
|
||||||
|
# The mirror refreshed the merged collection's metadata (type from registry).
|
||||||
|
assert conn.execute("SELECT type FROM collections WHERE id='default'").fetchone()["type"] == "document"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_skips_when_default_collection_already_exists(monkeypatch):
|
||||||
|
# A prior buggy mirror already created a 'default' collection alongside the
|
||||||
|
# divergent 'ohm' one: don't auto-merge data — leave both for operator cleanup.
|
||||||
|
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||||
|
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
||||||
|
"VALUES ('default','ohm','document','','super-draft','public','dup')")
|
||||||
|
projects.reconcile_default_collection_id(cfg)
|
||||||
|
# Both still present (no destructive auto-merge).
|
||||||
|
assert conn.execute("SELECT 1 FROM collections WHERE id='ohm'").fetchone() is not None
|
||||||
|
assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None
|
||||||
@@ -57,12 +57,15 @@ def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea
|
|||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert r.json()["state"] == "retired"
|
assert r.json()["state"] == "retired"
|
||||||
|
|
||||||
# Meta entry on main: state retired, body + fields kept.
|
# §22.4a SLICE-4: the state flip lands in the metadata sidecar and the
|
||||||
meta = entry_mod.parse(
|
# `.md` is lazy-migrated to a clean body-only file (INV-2). Fields kept.
|
||||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
import yaml as _yaml
|
||||||
|
sc = _yaml.safe_load(
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
|
||||||
)
|
)
|
||||||
assert meta.state == "retired"
|
assert sc["state"] == "retired"
|
||||||
assert "carol" in meta.owners
|
assert "carol" in sc["owners"]
|
||||||
|
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||||
|
|
||||||
# Cache flipped; gone from the catalog.
|
# Cache flipped; gone from the catalog.
|
||||||
cached = db.conn().execute(
|
cached = db.conn().execute(
|
||||||
@@ -177,11 +180,12 @@ def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_w
|
|||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert r.json()["state"] == "active"
|
assert r.json()["state"] == "active"
|
||||||
|
|
||||||
meta = entry_mod.parse(
|
import yaml as _yaml
|
||||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
sc = _yaml.safe_load(
|
||||||
|
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
|
||||||
)
|
)
|
||||||
assert meta.state == "active"
|
assert sc["state"] == "active"
|
||||||
assert meta.id == "RFC-0042"
|
assert sc["id"] == "RFC-0042"
|
||||||
|
|
||||||
cached = db.conn().execute(
|
cached = db.conn().execute(
|
||||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
#
|
#
|
||||||
# while IFS='=' read -r k v; do
|
# while IFS='=' read -r k v; do
|
||||||
# [ -n "$k" ] && case "$k" in \#*) ;; *) \
|
# [ -n "$k" ] && case "$k" in \#*) ;; *) \
|
||||||
# ohm-rfc-app-flotilla overlay set ohm-rfc-app "$k=$v" --preview ;; esac
|
# flotilla-core overlay set <deployment> "$k=$v" --preview ;; esac
|
||||||
# done < deploy/preview/preview.env.example
|
# done < deploy/preview/preview.env.example
|
||||||
#
|
#
|
||||||
# CRITICAL (§15 / §3 invariant 1): a preview resolves ZERO real secret bytes.
|
# CRITICAL (§15 / §3 invariant 1): a preview resolves ZERO real secret bytes.
|
||||||
|
|||||||
@@ -377,6 +377,16 @@ Amend the binding contract first, then build storage/compat, then schema, then r
|
|||||||
#### SLICE-4 — Single-entry metadata edit → completes PUC-1
|
#### SLICE-4 — Single-entry metadata edit → completes PUC-1
|
||||||
- **Depends on:** SLICE-2
|
- **Depends on:** SLICE-2
|
||||||
- **DoD:** detail panel renders schema controls; `POST …/meta` validates, direct-commits, re-ingests; scope-role gated (INV-4); lazy-migrates a legacy entry on first edit.
|
- **DoD:** detail panel renders schema controls; `POST …/meta` validates, direct-commits, re-ingests; scope-role gated (INV-4); lazy-migrates a legacy entry on first edit.
|
||||||
|
- **Carried from SLICE-1 (deferred there):** make the **write paths**
|
||||||
|
sidecar-aware — every site that today does `entry.parse(<slug>.md)` and
|
||||||
|
serializes back into the `.md` must read/write metadata via the sidecar so a
|
||||||
|
migrated (body-only) entry doesn't crash or re-grow frontmatter. The known
|
||||||
|
sites: graduation + claim + `_read_meta_entry` (`api_graduation.py`),
|
||||||
|
`mark_entry_reviewed` (`bot.py`), body-edit / accept-change wrappers
|
||||||
|
(`api_branches.py` `_wrap_body`/`_extract_body`), and the PR-replay wrappers
|
||||||
|
(`api_prs.py`). Only once these are sidecar-aware should the **operator
|
||||||
|
trigger** for `metadata.migrate_collection` (the Owner-gated migrate endpoint)
|
||||||
|
ship.
|
||||||
|
|
||||||
#### SLICE-5 — Bulk tag/untag → completes PUC-2
|
#### SLICE-5 — Bulk tag/untag → completes PUC-2
|
||||||
- **Depends on:** SLICE-3, SLICE-4
|
- **Depends on:** SLICE-3, SLICE-4
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Deployed-environment E2E harness (PPE)
|
||||||
|
|
||||||
|
**Date:** 2026-06-07 · **Version:** v0.52.0 · **Status:** implemented
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The §9 deployment pipeline is `localhost + E2E → PPE + E2E → prod`. The
|
||||||
|
middle stage — running the Playwright E2E suite against a *deployed*
|
||||||
|
pre-prod host (`https://rfc-ppe.wiggleverse.org`) — was unreachable
|
||||||
|
because the suite (`e2e/metadata.spec.js`, SLICE-3/4/5 of the
|
||||||
|
configurable-collection-metadata work) was bound to three scaffolds that
|
||||||
|
exist only in the local Tier-1 docker stack:
|
||||||
|
|
||||||
|
1. **A faceted `bdd` collection**, seeded into a throwaway Gitea by
|
||||||
|
`testing/seed-gitea.sh`.
|
||||||
|
2. **A granted-owner identity** (`e2e-owner@example.test`), injected
|
||||||
|
directly into SQLite by the docker-compose `backend-seed` step.
|
||||||
|
3. **Mailpit**, the SMTP sink the OTC sign-in reads the one-time code
|
||||||
|
from.
|
||||||
|
|
||||||
|
PPE has none of these: it runs against the real `git.wiggleverse.org`
|
||||||
|
(shared with prod), has no direct DB access, and has no mail sink. This
|
||||||
|
note records how each coupling is replaced so the *same* spec runs green
|
||||||
|
against both localhost and PPE.
|
||||||
|
|
||||||
|
## The three seams
|
||||||
|
|
||||||
|
### 1. Auth — a gated test-login endpoint (the framework change)
|
||||||
|
|
||||||
|
A new backend route, `POST /auth/test/login`, replaces both the Mailpit
|
||||||
|
OTC dance *and* the SQLite owner injection with one gesture: it mints an
|
||||||
|
authenticated **owner** session for a single pre-configured identity.
|
||||||
|
|
||||||
|
It is the framework's only auth bypass, so it is **fail-closed** and must
|
||||||
|
never function in production:
|
||||||
|
|
||||||
|
- **Off by default.** It returns `404` unless **both**
|
||||||
|
`E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL` are set. A production
|
||||||
|
deployment sets neither, so the route is invisible and inert.
|
||||||
|
- **Secret-gated.** The caller must present `E2E_TEST_AUTH_SECRET` in the
|
||||||
|
`X-Test-Auth-Secret` header, compared in constant time. A wrong/absent
|
||||||
|
secret returns `404` (it does not advertise the route's existence).
|
||||||
|
- **Single identity.** It will only mint the one configured
|
||||||
|
`E2E_TEST_AUTH_EMAIL` (case-insensitive); any other address is `403`.
|
||||||
|
So an enabled PPE exposes exactly one throwaway owner, with the secret
|
||||||
|
as the trust boundary.
|
||||||
|
- **Loud at startup.** When enabled, the app logs a `WARNING` at boot, so
|
||||||
|
an accidental prod enablement is visible rather than silent.
|
||||||
|
|
||||||
|
On success it provision-or-links the row (reusing `otc.provision_or_link_user`),
|
||||||
|
forces it to `role='owner', permission_state='granted'` (the deployed
|
||||||
|
equivalent of the Tier-1 owner-seed), and stores the session exactly like
|
||||||
|
the OTC verify path.
|
||||||
|
|
||||||
|
**Why an endpoint rather than alternatives.** Reading the OTC code from
|
||||||
|
the VM's journald (the email adapter logs the envelope to stdout when
|
||||||
|
SMTP is unconfigured) would couple the test harness to `gcloud` SSH at
|
||||||
|
runtime — slow, brittle, and operator-cred-bound. Running Mailpit on the
|
||||||
|
VM and exposing its API publicly is more infra and its own exposure
|
||||||
|
surface. A default-off, secret-gated endpoint is the portable engineering
|
||||||
|
seam: it works for *any* deployed environment, needs no SSH, and the
|
||||||
|
secrets rule (§6.3) is honored — the secret is a Secret Manager ref
|
||||||
|
injected as VM env, never a literal.
|
||||||
|
|
||||||
|
The hard-secrets caveat: the E2E runner presents the secret by resolving
|
||||||
|
it from Secret Manager at runtime (command substitution), never echoing
|
||||||
|
it.
|
||||||
|
|
||||||
|
### 2. Content — a dedicated PPE registry + content repo
|
||||||
|
|
||||||
|
PPE shares the prod Gitea org (`wiggleverse`) and, until now, prod's
|
||||||
|
registry (`rfc-registry`) and default project (`ohm`). Seeding a faceted
|
||||||
|
test collection into that shared registry would surface it on **prod**.
|
||||||
|
|
||||||
|
So PPE gets its **own**, prod-untouching fixtures:
|
||||||
|
|
||||||
|
- `wiggleverse/rfc-registry-ppe` — PPE's project registry. Prod keeps
|
||||||
|
`rfc-registry`, so prod is never affected.
|
||||||
|
- `wiggleverse/rfc-app-ppe-content` — one project `ohm` (document) with a
|
||||||
|
default collection entry plus a faceted `bdd` named collection
|
||||||
|
(`priority` enum + `tags`) and three entries, mirroring the Tier-1
|
||||||
|
seed. The E2E path `/p/ohm/c/bdd` therefore resolves identically on
|
||||||
|
both environments.
|
||||||
|
|
||||||
|
PPE is pointed at it with `overlay set rfc-app-ppe
|
||||||
|
REGISTRY_REPO=rfc-registry-ppe`. The startup reconciler sweep loads the
|
||||||
|
content into `cached_rfcs` (incl. `meta_json` for facets) on the next
|
||||||
|
deploy — no webhook needed for the initial load. The seed is scripted in
|
||||||
|
`testing/seed-ppe.sh` (idempotent; `RESEED=1` restores entry values for a
|
||||||
|
re-run). Repo *creation* is a one-time operator gesture (the
|
||||||
|
`write:repository` Keychain token cannot create org repos; create the two
|
||||||
|
empty repos in the Gitea UI or re-scope the PAT).
|
||||||
|
|
||||||
|
### 3. Parameterization — one spec, two environments
|
||||||
|
|
||||||
|
- `e2e/playwright.config.js` already honors `BASE_URL`
|
||||||
|
(default `http://localhost:8080`); PPE sets
|
||||||
|
`BASE_URL=https://rfc-ppe.wiggleverse.org`.
|
||||||
|
- `e2e/lib/auth.js` branches on `E2E_TEST_AUTH_SECRET`: set → use
|
||||||
|
`/auth/test/login`; unset → the original Mailpit OTC path. `OWNER_EMAIL`
|
||||||
|
reads `E2E_OWNER_EMAIL` (PPE points it at `E2E_TEST_AUTH_EMAIL`) or the
|
||||||
|
Tier-1 default. The spec itself is unchanged, so the localhost Tier-1
|
||||||
|
path keeps working.
|
||||||
|
|
||||||
|
## PPE version
|
||||||
|
|
||||||
|
The harness *requires* the test-login endpoint to exist in the deployed
|
||||||
|
build, so PPE must run a framework version that contains it — **v0.52.0**,
|
||||||
|
not v0.51.1. PPE is pinned ahead of prod via its own
|
||||||
|
`ben/ohm-rfc/.rfc-app-version.ppe` (prod stays on `.rfc-app-version`),
|
||||||
|
realizing the "PPE stages newer versions first" note the
|
||||||
|
`deployment.ppe.toml` always anticipated.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **Re-runnability.** SLICE-4/5 mutate the seeded entries (commit
|
||||||
|
sidecars). A clean run needs seed-state preconditions; re-run after
|
||||||
|
`RESEED=1` + a cache refresh (next reconciler sweep or a redeploy).
|
||||||
|
Unlike Tier-1's `make e2e-fresh`, PPE has no per-run teardown.
|
||||||
|
- **smoke.spec.js** stays Tier-1-only (anonymous OTC smoke through
|
||||||
|
Mailpit); only `metadata.spec.js` runs against PPE.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# SLICE-1 plan — sidecar storage + dual-read + migration + malformed flag
|
||||||
|
|
||||||
|
Just-in-time implementation plan for **SLICE-1** of
|
||||||
|
[Configurable Collection Metadata](../2026-06-06-configurable-collection-metadata.md)
|
||||||
|
(§7.2). Authored at the start of the SLICE-1 coding session (session 0084,
|
||||||
|
2026-06-07), against the code SLICE-0 (v0.46.2) landed.
|
||||||
|
|
||||||
|
## Scope (and non-scope)
|
||||||
|
|
||||||
|
**In:** the storage/compat layer only — sidecar files become the source of
|
||||||
|
truth for entry metadata, with a dual-read parser, an idempotent
|
||||||
|
frontmatter→sidecar migration tool, and a derived `metadata_malformed` flag.
|
||||||
|
|
||||||
|
**Out (later slices):** the `.collection.yaml` `fields:` schema + validation
|
||||||
|
(SLICE-2), faceted filtering (SLICE-3), the edit/bulk UIs (SLICE-4/5). SLICE-1
|
||||||
|
maps sidecar values onto the **existing** typed `cached_rfcs` columns; it does
|
||||||
|
not add per-field schema columns or facet aggregation.
|
||||||
|
|
||||||
|
## Invariants honored
|
||||||
|
|
||||||
|
- **INV-6 dual-read:** parser reads the sidecar if present, else legacy
|
||||||
|
top-of-doc frontmatter, with identical resulting in-memory records.
|
||||||
|
- **INV-7 unknown keys ride along:** preserved through parse→serialize and
|
||||||
|
through the migration (never dropped).
|
||||||
|
- **INV-2:** a migrated `.md` body contains no metadata.
|
||||||
|
- **INV-1:** `cached_rfcs` stays a derived, rebuildable index; the sidecar in
|
||||||
|
git is the source of truth.
|
||||||
|
- **INV-3:** bad metadata never hard-fails a read — the entry still loads and
|
||||||
|
the catalog flags it (`metadata_malformed`).
|
||||||
|
- **INV-5 / byte-identity:** a collection with no sidecars behaves exactly as
|
||||||
|
today (legacy frontmatter path); existing entries load identically.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
1. **`app/entry.py` — unknown-key preservation (INV-7).** Add
|
||||||
|
`extra: dict[str, Any]` to `Entry`. `parse()` collects frontmatter keys
|
||||||
|
outside the known set into `extra`; `serialize()` re-emits them after the
|
||||||
|
known keys. Makes frontmatter round-trips lossless.
|
||||||
|
|
||||||
|
2. **`app/metadata.py` — new module (sidecar concerns).**
|
||||||
|
- `SIDECAR_SUFFIX = ".meta.yaml"`; `sidecar_name(slug)`,
|
||||||
|
`is_sidecar(name)`, `slug_of_sidecar(name)`.
|
||||||
|
- `metadata_dict(entry) -> dict` — the full metadata mapping (known
|
||||||
|
emit-rules + `extra`), shared by the sidecar writer and the frontmatter
|
||||||
|
serializer.
|
||||||
|
- `sidecar_yaml(entry) -> str` — canonical YAML for a sidecar from
|
||||||
|
`metadata_dict`.
|
||||||
|
- `strip_frontmatter(md_text) -> str` — body-only (drops a leading
|
||||||
|
`---…---` block if present; whole text otherwise).
|
||||||
|
- `parse_sidecar(text) -> tuple[dict, bool]` — lenient: `(values, malformed)`;
|
||||||
|
non-mapping / YAML error → `({}, True)`.
|
||||||
|
- `read_entry(md_text, sidecar_text|None) -> tuple[Entry, bool]` — dual-read:
|
||||||
|
sidecar present → metadata from sidecar values, body from
|
||||||
|
`strip_frontmatter(md_text)`, `malformed` from `parse_sidecar`; absent →
|
||||||
|
`entry.parse(md_text)`, `malformed=False`.
|
||||||
|
|
||||||
|
3. **`app/gitea.py` — `change_files(...)` batch commit.** `POST
|
||||||
|
/repos/{owner}/{repo}/contents` (Gitea ChangeFiles) with a `files[]` array
|
||||||
|
of `{operation, path, content(b64), sha?}` — one commit for N files. Backs
|
||||||
|
the migration's "one commit per collection".
|
||||||
|
|
||||||
|
4. **`metadata.migrate_collection(gitea, org, repo, subfolder, actor)`.**
|
||||||
|
Lists `<subfolder>/rfcs`; for each `<slug>.md` **without** a `<slug>.meta.yaml`
|
||||||
|
sibling and **with** legacy frontmatter, batch: create the sidecar
|
||||||
|
(`metadata_dict` → YAML) + update the `.md` to body-only. One ChangeFiles
|
||||||
|
commit per collection. Idempotent (skip entries already migrated; no-op when
|
||||||
|
none remain). Returns a summary (`migrated`, `skipped`, `committed`).
|
||||||
|
|
||||||
|
> **Deferred (decided mid-slice, after code review):** the **operator
|
||||||
|
> trigger** for this tool (an Owner-gated endpoint) is held back to SLICE-4.
|
||||||
|
> The propose/graduate/mark-reviewed/edit write paths still `entry.parse` the
|
||||||
|
> `.md` directly, so migrating a corpus to body-only `.md`s before those
|
||||||
|
> paths are sidecar-aware would break them (crash / re-introduce
|
||||||
|
> frontmatter). SLICE-1 ships the tool as tested groundwork; SLICE-4 makes
|
||||||
|
> the write paths sidecar-aware (and adds lazy migration) and is where the
|
||||||
|
> trigger belongs (INV-8: engine write paths unchanged this slice).
|
||||||
|
|
||||||
|
5. **`app/cache.py` — dual-read in `_refresh_collection_corpus`.** Build a
|
||||||
|
sidecar-by-stem map from the dir listing; for each `.md`, read its sidecar
|
||||||
|
sibling (if any), `metadata.read_entry(...)`, thread `malformed` into
|
||||||
|
`_upsert_cached_rfc(metadata_malformed=…)`.
|
||||||
|
|
||||||
|
6. **`backend/migrations/033_metadata_malformed.sql`** — additive
|
||||||
|
`ALTER TABLE cached_rfcs ADD COLUMN metadata_malformed INTEGER NOT NULL DEFAULT 0`.
|
||||||
|
|
||||||
|
7. **`app/api.py` — surface the flag.** Add `metadata_malformed` (bool) to the
|
||||||
|
two catalog list dicts and `get_rfc`/`_get_rfc_for_collection`. (Frontend
|
||||||
|
badge + `?malformed=` filter are SLICE-3.)
|
||||||
|
|
||||||
|
## Tests (TDD — write first)
|
||||||
|
|
||||||
|
- `test_metadata.py` (unit, pure): dual-read equivalence (sidecar vs legacy →
|
||||||
|
identical Entry); unknown-key preservation through parse→serialize and
|
||||||
|
through `metadata_dict`; `strip_frontmatter` (with/without frontmatter);
|
||||||
|
`parse_sidecar` malformed cases.
|
||||||
|
- `test_metadata_migration.py` (integration, FakeGitea): migrate a collection
|
||||||
|
→ sidecars written + `.md` bodies stripped + one commit; **idempotent**
|
||||||
|
(second run is a no-op); unknown keys preserved in the sidecar.
|
||||||
|
- extend the cache/propose vertical: a collection with a sidecar mirrors from
|
||||||
|
the sidecar; a malformed sidecar sets `metadata_malformed` and still loads
|
||||||
|
the entry (INV-3); a no-sidecar collection is byte-identical to today.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
Minor bump **0.46.2 → 0.47.0** (new functionality: sidecar storage + migration
|
||||||
|
tool; non-breaking — additive migration 033, dual-read keeps legacy corpora
|
||||||
|
working, opt-in). §20 CHANGELOG + upgrade-steps: migration 033 auto-applies;
|
||||||
|
running the migration tool per collection is optional (**MAY**).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Implementation plan — SLICE-2: collection field schema + central validation
|
||||||
|
|
||||||
|
**Slice:** SLICE-2 of
|
||||||
|
[`docs/design/2026-06-06-configurable-collection-metadata.md`](../2026-06-06-configurable-collection-metadata.md)
|
||||||
|
§7.2. **Session:** OHM-0085. **Branch:** `worktree-metadata-slice2-schema`.
|
||||||
|
|
||||||
|
## Goal / Definition of Done (from the design)
|
||||||
|
|
||||||
|
- `.collection.yaml` `fields:` block is parsed and stored.
|
||||||
|
- `metadata_schema.validate` exists — **advisory at read**, the enforcement
|
||||||
|
point **at write** (write endpoints land in SLICE-4/5; this slice supplies and
|
||||||
|
read-wires the function).
|
||||||
|
- The schema is served via the collection API (`GET …/collections/{id}`).
|
||||||
|
- A collection with **no `fields:`** behaves exactly as today (INV-5) — the
|
||||||
|
§22.13 default `document` collection sees zero change.
|
||||||
|
|
||||||
|
## Design decisions (this slice)
|
||||||
|
|
||||||
|
- **Field-def shape** (design §6.3): `fields:` is an ordered mapping
|
||||||
|
`{name → {type, values?, label?}}`. `type ∈ {enum, tags, text}` (v1).
|
||||||
|
Order is preserved for facet display (SLICE-3).
|
||||||
|
- `enum` — single scalar; requires a non-empty `values:` list.
|
||||||
|
- `tags` — list; `values:` optional (controlled when present, free-form
|
||||||
|
otherwise).
|
||||||
|
- `text` — free string scalar.
|
||||||
|
- **`ref` and `multi-enum` are out of v1** (design §2 future; Q2 leans "later").
|
||||||
|
Unknown field types are **ignored with a warning** (design §6.3), not fatal.
|
||||||
|
- **Lenient schema parsing.** A malformed `fields:` block or an individual bad
|
||||||
|
field def is skipped with a warning, never raised — a typo in one field must
|
||||||
|
not nuke the collection mirror (INV-3 spirit). Structural manifest errors
|
||||||
|
(`type`, `visibility`) keep raising `RegistryError` as before.
|
||||||
|
- **No DB migration.** The normalized `fields` schema rides in the existing
|
||||||
|
`collections.config_json` column, exactly like `enabled_models`.
|
||||||
|
- **`values` source for validation.** An entry's full metadata mapping
|
||||||
|
(`metadata.metadata_dict(entry)` = `to_frontmatter_dict`) — `tags` come from
|
||||||
|
the known `Entry.tags`; custom fields (e.g. `priority`) come from
|
||||||
|
`Entry.extra`. Undeclared keys are forward-compat (INV-7) and never flagged.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### 1. `app/metadata_schema.py` (new) — the one place that knows field shapes
|
||||||
|
- `VALID_FIELD_TYPES = {"enum", "tags", "text"}`.
|
||||||
|
- `parse_fields(raw) -> dict[str, dict]` — lenient/normalizing. Returns an
|
||||||
|
ordered mapping of `{name: {"type", "values"?, "label"?}}`. Skips: non-mapping
|
||||||
|
block; non-mapping field def; unknown/missing type; `enum` without a non-empty
|
||||||
|
`values:` list. Logs a warning per skip. Pure (no I/O).
|
||||||
|
- `@dataclass Problem(field, code, message)` + `as_dict()`.
|
||||||
|
- `validate(values: dict, fields: dict[str, dict]) -> list[Problem]` — for each
|
||||||
|
**declared** field, validate the entry's value (absent is OK):
|
||||||
|
- `enum`: scalar ∈ `values:` else `not-in-values`; a list/non-scalar →
|
||||||
|
`wrong-type`.
|
||||||
|
- `tags`: must be a list (`wrong-type` otherwise); if controlled, each member
|
||||||
|
∈ `values:` else `not-in-values`.
|
||||||
|
- `text`: must be a scalar string (`wrong-type` otherwise).
|
||||||
|
Undeclared keys ignored (INV-7). Never raises.
|
||||||
|
- **Tests** `tests/test_metadata_schema.py`: parse (each type, missing block,
|
||||||
|
enum-without-values skipped, unknown type skipped, order preserved); validate
|
||||||
|
(happy, enum bad value, tags uncontrolled-ok, tags controlled-bad, text wrong
|
||||||
|
type, undeclared key ignored, empty schema → no problems).
|
||||||
|
|
||||||
|
### 2. `registry.py` — parse `fields:` into the collection config
|
||||||
|
- In `parse_collection_manifest`, after the existing keys: if `raw.get("fields")`
|
||||||
|
present, `cfg["fields"] = metadata_schema.parse_fields(raw["fields"])` (only
|
||||||
|
set when non-empty). Flows into `CollectionEntry.config` →
|
||||||
|
`config_json` via the existing `json.dumps(ce.config)` in
|
||||||
|
`_upsert_named_collection`.
|
||||||
|
- **Default collection** (`apply_registry`) currently writes the `collections`
|
||||||
|
row **without** `config_json`. A default collection's `fields:` would live in
|
||||||
|
`projects.yaml`? No — the design says `fields:` is a `.collection.yaml` block.
|
||||||
|
The default collection has no `.collection.yaml`. **Decision:** default-
|
||||||
|
collection field schemas are out of this slice's happy path (the N=1 default is
|
||||||
|
`document` with no fields, INV-5). Leave `apply_registry` untouched; only
|
||||||
|
named collections (with a `.collection.yaml`) carry `fields:`. Documented as a
|
||||||
|
known limitation (a deployment wanting fields on its primary corpus declares a
|
||||||
|
named collection — consistent with the design's opt-in story).
|
||||||
|
- **Tests** extend `tests/test_collection_registry.py`: a manifest with a
|
||||||
|
`fields:` block round-trips into `get_collection(...)["fields"]`; a manifest
|
||||||
|
with a bad field def still upserts (lenient).
|
||||||
|
|
||||||
|
### 3. `collections.py` — unpack `fields` on read + serve via API
|
||||||
|
- Add `_fields_from_config(config_json) -> dict | None` (mirror
|
||||||
|
`_enabled_models_from_config`).
|
||||||
|
- `get_collection` sets `out["fields"] = _fields_from_config(config_json)`
|
||||||
|
(alongside `enabled_models`). `api_collections.get_col` then serves it with no
|
||||||
|
change. `list_collections` left as-is (facets are SLICE-3).
|
||||||
|
- **Tests** extend `tests/test_collection_helpers.py`: `get_collection` exposes
|
||||||
|
`fields`; a no-fields collection → `fields is None` (INV-5).
|
||||||
|
|
||||||
|
### 4. `cache.py` — advisory validation at ingest (INV-3)
|
||||||
|
- In `_refresh_collection_corpus`, fetch the collection's `fields` schema once
|
||||||
|
(`collections.get_collection(collection_id)`); for each entry, if a schema is
|
||||||
|
present, `problems = metadata_schema.validate(metadata.metadata_dict(entry),
|
||||||
|
fields)` and OR any problems into `metadata_malformed` (warn-log a summary).
|
||||||
|
No schema → behavior identical to today (INV-5).
|
||||||
|
- **Tests** `tests/test_metadata_cache.py` (extend): an entry violating an enum
|
||||||
|
field ingests with `metadata_malformed = 1`; a conforming entry → `0`; a
|
||||||
|
collection with no schema → `0` regardless of extra keys.
|
||||||
|
|
||||||
|
### 5. Verify · version · ship
|
||||||
|
- `pytest` full backend suite green (575 baseline + new).
|
||||||
|
- Bump `VERSION` + `frontend/package.json` → **0.48.0**; CHANGELOG minor entry
|
||||||
|
(§20) — non-breaking, opt-in, N=1 unchanged; note write-enforcement lands with
|
||||||
|
SLICE-4/5.
|
||||||
|
- Commit (cite design §7.2 SLICE-2), PR on Gitea `origin`, merge to `main`.
|
||||||
|
|
||||||
|
## Invariants honored
|
||||||
|
INV-3 (read never hard-fails — advisory malformed), INV-5 (no-`fields:`
|
||||||
|
unchanged), INV-7 (undeclared keys ride along, never flagged), INV-8 (additive,
|
||||||
|
read-mostly; no write-path fork — write enforcement is a later slice).
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,802 @@
|
|||||||
|
# SLICE-5 — Bulk tag/untag Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let an authorized user apply one metadata field change (set / add-tag / remove-tag) to many catalog entries at once, committed as a single git commit, with per-entry partial-rejection reported — completing PUC-2 of the [Configurable Collection Metadata](../../design/2026-06-06-configurable-collection-metadata.md) design (§6.4, §6.5, D7).
|
||||||
|
|
||||||
|
**Architecture:** A new backend endpoint `POST /api/projects/{pid}/collections/{cid}/meta/bulk` reuses the SLICE-4 sidecar-aware git helpers (`metadata.read_entry_from_git` / `apply_values` / `write_entry_files`) and the `bot.commit_entry_files` multi-file primitive: read each entry, apply the op, validate at the write boundary (INV-4), collect file ops for the passing entries, and commit them all in **one** `change_files` call (D7: bulk = 1 commit). Entries that fail validation or are missing are reported in `rejected`, others in `applied`. The frontend adds row multi-select + a sticky bulk action bar in `Catalog.jsx` (faceted, contributor+ only), driven by the collection `fields:` schema, calling a new `bulkEntryMeta` API helper and toasting partial rejections.
|
||||||
|
|
||||||
|
**Tech Stack:** Python / FastAPI / pytest (backend); React / Vitest (frontend); Gitea ChangeFiles for the one-commit write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `backend/app/api_metadata.py` — **modify**: add the `bulk_meta` route + a `BulkMetaBody` model alongside the existing single-edit and migrate routes. Add a small pure helper `_apply_op(entry, op, field, value)` (or inline) computing the new field value for set/add/remove.
|
||||||
|
- `backend/tests/test_metadata_bulk_endpoint.py` — **create**: endpoint tests (one commit, partial reject, authz, op validation), reusing the `test_propose_vertical` fake-Gitea harness like `test_metadata_edit_endpoint.py`.
|
||||||
|
- `frontend/src/api.js` — **modify**: add `bulkEntryMeta(projectId, collectionId, { slugs, op, field, value })`.
|
||||||
|
- `frontend/src/components/BulkActionBar.jsx` — **create**: the sticky bar (N selected · Set <enum> ▾ · Add/Remove tag · Clear), driven by `fields`.
|
||||||
|
- `frontend/src/components/BulkActionBar.test.jsx` — **create**: render + interaction unit tests.
|
||||||
|
- `frontend/src/components/Catalog.jsx` — **modify**: per-row selection checkboxes (faceted + contributor only), selection state, render `BulkActionBar`, apply handler + toast, re-fetch on success.
|
||||||
|
- `CHANGELOG.md`, `VERSION`, `frontend/package.json` — **modify**: v0.51.0 release entry + version bump.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend op semantics (reference for all backend tasks)
|
||||||
|
|
||||||
|
Endpoint: `POST /api/projects/{pid}/collections/{cid}/meta/bulk`
|
||||||
|
Body: `{ "slugs": ["a","b"], "op": "set"|"add"|"remove", "field": "priority", "value": <any> }`
|
||||||
|
Response (200): `{ "ok": true, "applied": ["a"], "rejected": [{"slug":"b","reason":"..."}], "committed": true }`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- **Authz:** `auth.can_contribute_in_collection(viewer, collection_id)` — else 403 (anonymous → 403). Same gate as the single-entry edit.
|
||||||
|
- **Collection-in-project:** mismatch → 404. No `fields:` → 422. Empty `slugs` → 422. `op` not in `{set,add,remove}` → 422. `field` not in schema → 422.
|
||||||
|
- **`set`** valid for any field; new value = `value` as given.
|
||||||
|
- **`add` / `remove`** valid only for `tags`-type fields (else 422); they read the entry's current list and append / drop the single `value`.
|
||||||
|
- **Per entry:** read via `read_entry_from_git`; missing `.md` → reject `{slug, reason:"not found"}`. Apply op → `apply_values`. Validate `metadata_schema.validate(metadata_dict(new_entry), fields)`; problems → reject `{slug, reason:"<problem messages>"}`. If the resulting metadata equals the old, count as applied but emit **no** file op (avoid redundant writes). Otherwise collect `write_entry_files(md_path, new_entry, state)` ops.
|
||||||
|
- **Commit:** concatenate all passing entries' file ops into one list; if non-empty, one `bot.commit_entry_files(..., message="Bulk <op> <field>: N entries")`; `committed=true`. If empty (all rejected, or all no-op) → no commit, `committed=false`.
|
||||||
|
- **Re-ingest:** `cache.refresh_meta_repo` once when committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Bulk endpoint — happy path, one commit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/api_metadata.py`
|
||||||
|
- Test: `backend/tests/test_metadata_bulk_endpoint.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `backend/tests/test_metadata_bulk_endpoint.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""SLICE-5 — bulk metadata edit endpoint (PUC-2, §6.4/§6.5).
|
||||||
|
|
||||||
|
Through the real API: contributor+ gating (INV-4), set/add/remove ops,
|
||||||
|
validation at the write boundary, one commit for N sidecars (D7), and
|
||||||
|
partial-rejection reporting. Reuses the fake-Gitea harness.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import cache, db, gitea as gitea_mod
|
||||||
|
from app.config import load_config
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
PID = "default"
|
||||||
|
CID = "default"
|
||||||
|
BASE = f"/api/projects/{PID}/collections/{CID}"
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh():
|
||||||
|
cfg = load_config()
|
||||||
|
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_fields(schema):
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||||
|
(json.dumps({"fields": schema}),))
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_legacy(fake, slug, *, state="active", **front):
|
||||||
|
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
|
||||||
|
body = yaml.safe_dump(fm, sort_keys=False).strip()
|
||||||
|
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||||
|
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
|
||||||
|
|
||||||
|
|
||||||
|
def _login_owner(client):
|
||||||
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar(fake, slug):
|
||||||
|
return yaml.safe_load(
|
||||||
|
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")]["content"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_set_applies_to_all_and_one_commit(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P2")
|
||||||
|
_seed_legacy(fake, "b", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
commits_before = fake.change_files_calls
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a", "b"], "op": "set",
|
||||||
|
"field": "priority", "value": "P0"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert set(body["applied"]) == {"a", "b"}
|
||||||
|
assert body["rejected"] == []
|
||||||
|
assert body["committed"] is True
|
||||||
|
# exactly one ChangeFiles commit covered both entries (D7)
|
||||||
|
assert fake.change_files_calls - commits_before == 1
|
||||||
|
assert _sidecar(fake, "a")["priority"] == "P0"
|
||||||
|
assert _sidecar(fake, "b")["priority"] == "P0"
|
||||||
|
```
|
||||||
|
|
||||||
|
> NOTE: the test asserts `fake.change_files_calls` — a counter on the fake Gitea. If the fake does not already expose one, Step 3 adds it (see Task 1 Step 3a).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py::test_bulk_set_applies_to_all_and_one_commit -v`
|
||||||
|
Expected: FAIL (404 — route not defined, or AttributeError on `change_files_calls`).
|
||||||
|
|
||||||
|
- [ ] **Step 3a: Add a commit counter to the fake Gitea (only if missing)**
|
||||||
|
|
||||||
|
Inspect the fake's `change_files` in `backend/tests/test_propose_vertical.py`. If it has no call counter, add one. Find the fake class's `change_files` method and increment a counter:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def change_files(self, owner, repo, *, files, message, branch,
|
||||||
|
author_name=None, author_email=None):
|
||||||
|
self.change_files_calls = getattr(self, "change_files_calls", 0) + 1
|
||||||
|
# ... existing body unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
If a counter already exists under another name, use that name in the test instead and skip this step.
|
||||||
|
|
||||||
|
- [ ] **Step 3b: Implement the bulk route**
|
||||||
|
|
||||||
|
In `backend/app/api_metadata.py`, add the request model near `MetaEditBody`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BulkMetaBody(BaseModel):
|
||||||
|
slugs: list[str]
|
||||||
|
op: str
|
||||||
|
field: str
|
||||||
|
value: Any = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the pure op helper above `make_router` (module level):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _apply_op(entry: Any, op: str, field: str, value: Any) -> Any:
|
||||||
|
"""Return the new value for `field` after applying `op` to `entry`.
|
||||||
|
|
||||||
|
set → `value`; add/remove operate on the entry's current tags-list value
|
||||||
|
for `field` (the route restricts add/remove to tags-type fields).
|
||||||
|
"""
|
||||||
|
if op == "set":
|
||||||
|
return value
|
||||||
|
current = metadata_mod.metadata_dict(entry).get(field) or []
|
||||||
|
if not isinstance(current, list):
|
||||||
|
current = [current]
|
||||||
|
if op == "add":
|
||||||
|
return current if value in current else [*current, value]
|
||||||
|
if op == "remove":
|
||||||
|
return [x for x in current if x != value]
|
||||||
|
return value # unreachable; op validated by the route
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the route inside `make_router`, after the single-entry `edit_meta` route:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.post("/api/projects/{project_id}/collections/{collection_id}/meta/bulk")
|
||||||
|
async def bulk_meta(
|
||||||
|
project_id: str, collection_id: str,
|
||||||
|
body: BulkMetaBody, request: Request,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
viewer = auth.current_user(request)
|
||||||
|
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||||
|
raise HTTPException(404, "Collection not in project")
|
||||||
|
if not auth.can_contribute_in_collection(viewer, collection_id):
|
||||||
|
raise HTTPException(403, "Contributor access required to edit metadata")
|
||||||
|
col = collections_mod.get_collection(collection_id)
|
||||||
|
fields = (col or {}).get("fields") or {}
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(422, "Collection declares no editable fields")
|
||||||
|
if not body.slugs:
|
||||||
|
raise HTTPException(422, "Provide at least one entry")
|
||||||
|
if body.op not in ("set", "add", "remove"):
|
||||||
|
raise HTTPException(422, f"Unknown op: {body.op}")
|
||||||
|
if body.field not in fields:
|
||||||
|
raise HTTPException(422, f"Unknown field: {body.field}")
|
||||||
|
if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
|
||||||
|
raise HTTPException(422, f"op {body.op} requires a tags field")
|
||||||
|
|
||||||
|
org, repo = _content_repo()
|
||||||
|
applied: list[str] = []
|
||||||
|
rejected: list[dict[str, str]] = []
|
||||||
|
all_ops: list[dict[str, Any]] = []
|
||||||
|
for slug in body.slugs:
|
||||||
|
md_path = _md_path(collection_id, slug)
|
||||||
|
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||||
|
if st is None:
|
||||||
|
rejected.append({"slug": slug, "reason": "not found"})
|
||||||
|
continue
|
||||||
|
new_value = _apply_op(st.entry, body.op, body.field, body.value)
|
||||||
|
new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
|
||||||
|
problems = metadata_schema.validate(
|
||||||
|
metadata_mod.metadata_dict(new_entry), fields)
|
||||||
|
if problems:
|
||||||
|
rejected.append({"slug": slug,
|
||||||
|
"reason": "; ".join(p.message for p in problems)})
|
||||||
|
continue
|
||||||
|
applied.append(slug)
|
||||||
|
if metadata_mod.metadata_dict(new_entry) != metadata_mod.metadata_dict(st.entry):
|
||||||
|
all_ops.extend(metadata_mod.write_entry_files(md_path, new_entry, st))
|
||||||
|
|
||||||
|
committed = False
|
||||||
|
if all_ops:
|
||||||
|
n = len(applied)
|
||||||
|
msg = f"Bulk {body.op} {body.field}: {n} entr{'y' if n == 1 else 'ies'}"
|
||||||
|
try:
|
||||||
|
await bot.commit_entry_files(
|
||||||
|
viewer.as_actor(), org=org, repo=repo, files=all_ops,
|
||||||
|
message=msg, branch="main")
|
||||||
|
except GiteaError as e:
|
||||||
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
|
committed = True
|
||||||
|
await cache.refresh_meta_repo(config, gitea)
|
||||||
|
return {"ok": True, "applied": applied,
|
||||||
|
"rejected": rejected, "committed": committed}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `Problem` exposes `.message` (see `metadata_schema.Problem`/`as_dict`); confirm the attribute name when implementing and adjust the join if it differs.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py::test_bulk_set_applies_to_all_and_one_commit -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/api_metadata.py backend/tests/test_metadata_bulk_endpoint.py backend/tests/test_propose_vertical.py
|
||||||
|
git commit -m "feat(slice5): POST .../meta/bulk one-commit bulk metadata edit (§22.4a PUC-2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Bulk add/remove tags
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `backend/tests/test_metadata_bulk_endpoint.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_bulk_add_tag(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"tags": {"type": "tags"}})
|
||||||
|
_seed_legacy(fake, "a", tags=["x"])
|
||||||
|
_seed_legacy(fake, "b", tags=["x", "y"])
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a", "b"], "op": "add",
|
||||||
|
"field": "tags", "value": "y"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert set(r.json()["applied"]) == {"a", "b"}
|
||||||
|
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
|
||||||
|
# idempotent: "b" already had y → unchanged, still no duplicate
|
||||||
|
assert _sidecar(fake, "b")["tags"] == ["x", "y"] or "b" not in [
|
||||||
|
# b may be a no-op write skip; the cache still shows ["x","y"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_remove_tag(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"tags": {"type": "tags"}})
|
||||||
|
_seed_legacy(fake, "a", tags=["x", "y"])
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "remove",
|
||||||
|
"field": "tags", "value": "x"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["applied"] == ["a"]
|
||||||
|
assert _sidecar(fake, "a")["tags"] == ["y"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_add_remove_requires_tags_field(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "add",
|
||||||
|
"field": "priority", "value": "z"})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
```
|
||||||
|
|
||||||
|
> Simplify the `test_bulk_add_tag` "b" assertion to whatever the no-op-skip semantics produce — verify the sidecar for "b" still reads `["x", "y"]` if a sidecar was written, and don't assert a sidecar exists for "b" if it was a pure no-op. Adjust after observing the first run.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify they fail/pass appropriately**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v -k "add or remove"`
|
||||||
|
Expected: pass for the logic implemented in Task 1; fix the `test_bulk_add_tag` "b" assertion to match the no-op-skip behavior actually observed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Finalize assertions**
|
||||||
|
|
||||||
|
Edit the `test_bulk_add_tag` "b" branch to a concrete assertion based on the observed behavior (sidecar absent for a pure no-op, or present and equal to `["x","y"]`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify all pass**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/tests/test_metadata_bulk_endpoint.py
|
||||||
|
git commit -m "test(slice5): bulk add/remove tag ops + tags-field guard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Partial rejection, authz, op validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `backend/tests/test_metadata_bulk_endpoint.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_bulk_partial_reject_invalid_value(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P1")
|
||||||
|
_seed_legacy(fake, "missing-source", priority="P1") # has source
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
# invalid value rejects ALL (set value is global), so use a per-entry
|
||||||
|
# rejection: one slug exists, one does not.
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a", "ghost"], "op": "set",
|
||||||
|
"field": "priority", "value": "P0"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["applied"] == ["a"]
|
||||||
|
assert body["rejected"] == [{"slug": "ghost", "reason": "not found"}]
|
||||||
|
assert body["committed"] is True
|
||||||
|
assert _sidecar(fake, "a")["priority"] == "P0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_invalid_value_rejects_all_no_commit(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P1")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "set",
|
||||||
|
"field": "priority", "value": "ZZZ"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["applied"] == []
|
||||||
|
assert len(r.json()["rejected"]) == 1
|
||||||
|
assert r.json()["committed"] is False
|
||||||
|
assert ("wiggleverse", "meta", "main", "rfcs/a.meta.yaml") not in fake.files
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_forbidden_for_anonymous(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
r = client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "set",
|
||||||
|
"field": "priority", "value": "P0"})
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_unknown_field_and_op(app_with_fake_gitea):
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||||
|
_seed_legacy(fake, "a", priority="P0")
|
||||||
|
_refresh()
|
||||||
|
_login_owner(client)
|
||||||
|
assert client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "set",
|
||||||
|
"field": "nope", "value": "P0"}).status_code == 422
|
||||||
|
assert client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": ["a"], "op": "frobnicate",
|
||||||
|
"field": "priority", "value": "P0"}).status_code == 422
|
||||||
|
assert client.post(f"{BASE}/meta/bulk",
|
||||||
|
json={"slugs": [], "op": "set",
|
||||||
|
"field": "priority", "value": "P0"}).status_code == 422
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify pass**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v`
|
||||||
|
Expected: PASS (logic from Task 1 covers these). Fix any assertion mismatches.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/tests/test_metadata_bulk_endpoint.py
|
||||||
|
git commit -m "test(slice5): bulk partial-reject, authz, validation guards"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend API helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/api.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the helper**
|
||||||
|
|
||||||
|
After `saveEntryMeta` in `frontend/src/api.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// §22.4a SLICE-5 (PUC-2): apply one field op (set | add | remove) to many
|
||||||
|
// entries at once — one commit server-side; returns { applied, rejected }.
|
||||||
|
export async function bulkEntryMeta(projectId, collectionId, { slugs, op, field, value }) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/projects/${projectId}/collections/${collectionId}/meta/bulk`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slugs, op, field, value }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/api.js
|
||||||
|
git commit -m "feat(slice5): bulkEntryMeta API helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: BulkActionBar component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/components/BulkActionBar.jsx`
|
||||||
|
- Test: `frontend/src/components/BulkActionBar.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `frontend/src/components/BulkActionBar.test.jsx`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import BulkActionBar from './BulkActionBar.jsx'
|
||||||
|
|
||||||
|
const FIELDS = {
|
||||||
|
priority: { type: 'enum', values: ['P0', 'P1', 'P2'], label: 'Priority' },
|
||||||
|
tags: { type: 'tags', label: 'Tags' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BulkActionBar', () => {
|
||||||
|
it('shows the selected count', () => {
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={3} onApply={() => {}} onClear={() => {}} />)
|
||||||
|
expect(screen.getByText(/3 selected/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a set op when an enum value is chosen', () => {
|
||||||
|
const onApply = vi.fn()
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||||
|
fireEvent.change(screen.getByLabelText(/set priority/i), { target: { value: 'P0' } })
|
||||||
|
expect(onApply).toHaveBeenCalledWith({ op: 'set', field: 'priority', value: 'P0' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies an add-tag op', () => {
|
||||||
|
const onApply = vi.fn()
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/tag…/i), { target: { value: 'checkout' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add tag/i }))
|
||||||
|
expect(onApply).toHaveBeenCalledWith({ op: 'add', field: 'tags', value: 'checkout' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClear', () => {
|
||||||
|
const onClear = vi.fn()
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={2} onApply={() => {}} onClear={onClear} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /clear/i }))
|
||||||
|
expect(onClear).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/components/BulkActionBar.test.jsx`
|
||||||
|
Expected: FAIL (module not found).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the component**
|
||||||
|
|
||||||
|
Create `frontend/src/components/BulkActionBar.jsx`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// §22.4a SLICE-5 (PUC-2, UX §5.3): the sticky bulk action bar shown when ≥1
|
||||||
|
// catalog row is selected. Driven by the collection `fields:` schema — one
|
||||||
|
// "Set <field>" control per enum field, and an Add/Remove tag control per
|
||||||
|
// tags field. Each gesture calls onApply({ op, field, value }); the parent
|
||||||
|
// (Catalog) sends one bulk request and re-fetches.
|
||||||
|
|
||||||
|
const labelFor = (name, def) =>
|
||||||
|
def?.label || name.charAt(0).toUpperCase() + name.slice(1)
|
||||||
|
|
||||||
|
export default function BulkActionBar({ fields, count, onApply, onClear }) {
|
||||||
|
const [tagValue, setTagValue] = useState('')
|
||||||
|
const entries = Object.entries(fields || {})
|
||||||
|
const tagField = entries.find(([, d]) => d.type === 'tags')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bulk-action-bar">
|
||||||
|
<span className="bulk-count">{count} selected</span>
|
||||||
|
{entries
|
||||||
|
.filter(([, d]) => d.type === 'enum')
|
||||||
|
.map(([name, def]) => (
|
||||||
|
<label key={name} className="bulk-set">
|
||||||
|
<span>Set {labelFor(name, def)}</span>
|
||||||
|
<select
|
||||||
|
aria-label={`Set ${labelFor(name, def)}`}
|
||||||
|
value=""
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.value) onApply({ op: 'set', field: name, value: e.target.value })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{tagField && (
|
||||||
|
<div className="bulk-tags">
|
||||||
|
<input
|
||||||
|
placeholder="tag…"
|
||||||
|
value={tagValue}
|
||||||
|
onChange={e => setTagValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!tagValue.trim()}
|
||||||
|
onClick={() => { onApply({ op: 'add', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
|
||||||
|
>
|
||||||
|
Add tag
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!tagValue.trim()}
|
||||||
|
onClick={() => { onApply({ op: 'remove', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
|
||||||
|
>
|
||||||
|
Remove tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="bulk-clear" onClick={onClear}>Clear</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/components/BulkActionBar.test.jsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/BulkActionBar.jsx frontend/src/components/BulkActionBar.test.jsx
|
||||||
|
git commit -m "feat(slice5): BulkActionBar component (UX §5.3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Wire selection + bulk bar into Catalog
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/Catalog.jsx`
|
||||||
|
- Test: `frontend/src/components/Catalog.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `frontend/src/components/Catalog.test.jsx` a test that, with a faceted collection and a contributor viewer, selecting a row reveals the bulk bar. Inspect the existing Catalog.test.jsx mock setup first (how it mocks `../api` `listRFCs`/`getCollection`) and mirror it. Skeleton:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('reveals the bulk action bar when a row is selected (faceted + contributor)', async () => {
|
||||||
|
// mock getCollection → { viewer: { can_contribute: true }, fields: { priority: { type: 'enum', values: ['P0','P1'] } } }
|
||||||
|
// mock listRFCs → { items: [{ slug: 'a', title: 'A', state: 'active', tags: [] }], facets: {} }
|
||||||
|
// render Catalog within the same providers/router the other tests use
|
||||||
|
// click the row's selection checkbox
|
||||||
|
// expect screen.getByText(/1 selected/i) to be in the document
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill the mocks to match the file's existing pattern exactly.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/components/Catalog.test.jsx`
|
||||||
|
Expected: FAIL (no checkbox / no bulk bar).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement in `Catalog.jsx`**
|
||||||
|
|
||||||
|
1. Imports:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { bulkEntryMeta } from '../api'
|
||||||
|
import BulkActionBar from './BulkActionBar.jsx'
|
||||||
|
import { useToast } from '../context/...' // match how other components toast; see below
|
||||||
|
```
|
||||||
|
|
||||||
|
> Check how `ToastHost` is consumed elsewhere (e.g. `grep useToast frontend/src`). If there's no hook, accept an `onToast`/use `window`-level host the app already wires. If toasting is awkward here, fall back to an inline message line in the bulk bar area. Do not invent a toast system.
|
||||||
|
|
||||||
|
2. Selection state + reset on collection/filter change:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const [selected, setSelected] = useState(() => new Set())
|
||||||
|
```
|
||||||
|
|
||||||
|
Clear it in the collection-change effect (the `[version, pid, cid]` effect) and whenever the list re-fetches: add `setSelected(new Set())` alongside `setSelections({})` in the collection effect, and clear it after a successful bulk apply.
|
||||||
|
|
||||||
|
3. Toggle helper:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function toggleSelect(slug) {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(slug) ? next.delete(slug) : next.add(slug)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Show a checkbox per row **only in faceted mode and when `canContribute`**. The row is a `<Link>`; render the checkbox as a sibling before it inside a wrapper so the checkbox click doesn't navigate (`onClick={e => e.stopPropagation()}` on the checkbox, and don't nest it in the Link):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
filtered.map(r => {
|
||||||
|
const isActive = slug === r.slug
|
||||||
|
const isSuper = r.state === 'super-draft'
|
||||||
|
const selectable = faceted && canContribute
|
||||||
|
return (
|
||||||
|
<div key={r.slug} className={`catalog-row-wrap ${selectable ? 'selectable' : ''}`}>
|
||||||
|
{selectable && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="row-select"
|
||||||
|
aria-label={`select ${r.title}`}
|
||||||
|
checked={selected.has(r.slug)}
|
||||||
|
onChange={() => toggleSelect(r.slug)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={entryPath(pid, r.slug, cid)}
|
||||||
|
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
||||||
|
>
|
||||||
|
{/* ...existing row internals unchanged... */}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
> Preserve the existing row internals (`row-top`, `row-id`, malformed marker, `row-title`, `row-tags`) verbatim inside the `<Link>`.
|
||||||
|
|
||||||
|
5. Apply handler:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function applyBulk({ op, field, value }) {
|
||||||
|
const slugs = [...selected]
|
||||||
|
if (slugs.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await bulkEntryMeta(pid, cid, { slugs, op, field, value })
|
||||||
|
if (res.rejected?.length) {
|
||||||
|
// surface which entries failed; see toast note above
|
||||||
|
showToast?.(`${res.applied.length} updated, ${res.rejected.length} skipped`)
|
||||||
|
}
|
||||||
|
setSelected(new Set())
|
||||||
|
// re-fetch the list (re-run the facet effect): bump a local nonce or
|
||||||
|
// re-call listRFCs directly. Simplest: replicate the list fetch here.
|
||||||
|
const selObj = Object.fromEntries(
|
||||||
|
Object.entries(selections).map(([f, set]) => [f, [...set]]))
|
||||||
|
const d = await listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly })
|
||||||
|
setRfcs(d.items); setFacets(d.facets || {})
|
||||||
|
} catch (e) {
|
||||||
|
showToast?.(e.message || 'Bulk update failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Render the bar above the list when `selected.size > 0`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{faceted && canContribute && selected.size > 0 && (
|
||||||
|
<BulkActionBar
|
||||||
|
fields={fields}
|
||||||
|
count={selected.size}
|
||||||
|
onApply={applyBulk}
|
||||||
|
onClear={() => setSelected(new Set())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/components/Catalog.test.jsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add minimal styling**
|
||||||
|
|
||||||
|
Add CSS for `.bulk-action-bar` (sticky, visible bar) and `.row-select` / `.catalog-row-wrap` (flex row) to the catalog stylesheet. Find where `.catalog-row` is styled (`grep -rn "catalog-row" frontend/src`) and add the new rules in the same file.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/Catalog.jsx frontend/src/components/Catalog.test.jsx frontend/src/styles
|
||||||
|
git commit -m "feat(slice5): catalog row multi-select + bulk action bar wired (PUC-2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Full suites green
|
||||||
|
|
||||||
|
- [ ] **Step 1: Backend**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest -q`
|
||||||
|
Expected: all pass (prior 640 + new bulk tests).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Frontend**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run`
|
||||||
|
Expected: all pass (prior 44 + new BulkActionBar + Catalog tests).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix any regressions, then commit if anything changed.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Version bump + changelog
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `VERSION`, `frontend/package.json`, `CHANGELOG.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump version to 0.51.0**
|
||||||
|
|
||||||
|
Set `VERSION` to `0.51.0`; set `frontend/package.json#version` to `0.51.0` (mirror rule, SPEC §20).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the changelog entry**
|
||||||
|
|
||||||
|
Prepend a `## 0.51.0 — 2026-06-07` minor entry to `CHANGELOG.md` describing the bulk endpoint + bulk bar, citing §22.4a SLICE-5 / PUC-2 and the design doc. No deployment upgrade steps required (additive; opt-in per collection via `fields:`, INV-5) — state that explicitly.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add VERSION frontend/package.json CHANGELOG.md
|
||||||
|
git commit -m "release(slice5): v0.51.0 — bulk tag/untag metadata (§22.4a PUC-2 SLICE-5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- **Spec coverage:** DoD = "multi-select + bulk bar (Task 5/6); `POST …/meta/bulk` applies set/add/remove as one commit (Task 1/2); partial-rejection reported (Task 3)." Tests `test_bulk_one_commit` ≈ Task 1, `test_partial_reject` ≈ Task 3 (traceability §8). INV-1/4/8 honored by reusing SLICE-4 sidecar write-through. INV-5 (no `fields:` → unchanged): bulk bar gated on `faceted` + the endpoint 422s with no `fields:`.
|
||||||
|
- **Type consistency:** `bulkEntryMeta({slugs, op, field, value})` shape matches `BulkMetaBody` and `onApply({op, field, value})`. `_apply_op` is the single op interpreter.
|
||||||
|
- **Open confirmations during execution:** (a) `metadata_schema.Problem` attribute for the human message (`.message` vs `.detail`); (b) the fake Gitea commit counter name; (c) the toast mechanism in the frontend (use existing or fall back to inline). Each is called out at its task.
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
# Solution Design: Corpus Tree — universal directory-tree left pane
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Author(s)** | Ben Stull |
|
||||||
|
| **Reviewers / approvers** | Ben Stull |
|
||||||
|
| **Status** | `draft` |
|
||||||
|
| **Version** | v0.2.0 |
|
||||||
|
| **Source artifacts** | BDD corpus: §§1.9/4 below (this doc) · Prototype: brainstorm mockups, session 0081.0 (`.superpowers/brainstorm/`, not committed) · Reference: current `rfc-app` §7 Catalog + §22 three-tier + `DocsLayout` flyout nav · Supersedes: — |
|
||||||
|
|
||||||
|
**Change log**
|
||||||
|
|
||||||
|
| Date | Version | Change | By |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2026-06-06 | v0.1.0 | Initial draft (discovery session ohm 0081.0) | Ben Stull |
|
||||||
|
| 2026-06-06 | v0.2.0 | Reworked to the restructured Solution Design standard (two-part front; Pain Points; Business Actors vs Product Personas; renumbered) | Ben Stull |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Business Context
|
||||||
|
|
||||||
|
*The business lens — solution-agnostic throughout. No mechanism is proposed until §2.*
|
||||||
|
|
||||||
|
### 1.1 Executive Summary
|
||||||
|
|
||||||
|
Organizations keep large, living bodies of documentation, and the value in them
|
||||||
|
depends on people being able to find what they need and trust that it is current
|
||||||
|
and collectively maintained. This design targets two outcomes: readers can locate
|
||||||
|
any document by where it sits in a documentation body's own organization, and an
|
||||||
|
organization can bring an existing body of documentation under collaborative,
|
||||||
|
reviewed governance without disrupting how that documentation is already
|
||||||
|
arranged. The benefit accrues to readers (faster, more confident access),
|
||||||
|
contributors (a reviewed way to improve the docs), and the organization (a
|
||||||
|
governed, trustworthy knowledge base).
|
||||||
|
|
||||||
|
### 1.2 Background
|
||||||
|
|
||||||
|
The platform already governs collaboratively-edited documents organized as a
|
||||||
|
single shallow list within a body. Real organizational documentation — handbooks,
|
||||||
|
runbooks, design libraries — is instead deeply structured, and most of it already
|
||||||
|
exists as finished, authoritative content rather than passing through a
|
||||||
|
proposal-to-acceptance flow. There is currently no way to bring such a body onto
|
||||||
|
the platform's governance without flattening its structure, which is why
|
||||||
|
structured documentation bodies stay off-platform today.
|
||||||
|
|
||||||
|
### 1.3 Business Actors / Roles
|
||||||
|
|
||||||
|
Real-world roles, independent of any product.
|
||||||
|
|
||||||
|
| Role | Responsible for (in the business) |
|
||||||
|
| --- | --- |
|
||||||
|
| Reader | Finds and reads documents to do their work; needs current, authoritative content |
|
||||||
|
| Contributor | Proposes new documents and changes to existing ones |
|
||||||
|
| Maintainer | Reviews proposed changes and decides what becomes authoritative |
|
||||||
|
| Documentation steward | Owns an existing body of documentation and decides to bring it under collaborative governance |
|
||||||
|
|
||||||
|
### 1.4 Problem Statement
|
||||||
|
|
||||||
|
A reader cannot navigate a documentation body by its own structure, and an
|
||||||
|
organization cannot place an existing structured body under collaborative
|
||||||
|
governance without reorganizing it. The governance model assumes every document
|
||||||
|
is one entry in a single flat list and passes through a proposal flow — neither
|
||||||
|
of which holds for an established, deeply-organized documentation body whose
|
||||||
|
documents already exist as authoritative content.
|
||||||
|
|
||||||
|
### 1.5 Pain Points
|
||||||
|
|
||||||
|
| # | Pain | Who feels it | Cost / frequency today |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| PP-1 | In a large body presented as one flat list, a reader can't tell where a document sits or browse by area | Reader | Every lookup in a sizeable body; slow, error-prone, gives up |
|
||||||
|
| PP-2 | An existing structured body can't be brought under governance without rearranging its documents into a flat scheme | Documentation steward | Blocks adoption entirely for any living body — a rearrange is a non-starter |
|
||||||
|
| PP-3 | Contributors to an existing body have no governed, reviewed way to propose changes tied to where each document lives | Contributor, Maintainer | Changes happen outside review, or not at all; no shared record of why |
|
||||||
|
| PP-4 | When the documentation is briefly unreachable, the reader is left with nothing to orient by | Reader | Intermittent; erodes trust in the body as a dependable source |
|
||||||
|
|
||||||
|
### 1.6 Targeted Business Outcomes
|
||||||
|
|
||||||
|
| Outcome | Success metric | Baseline → Target | Guardrail (must not regress) | How / when measured |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Structured documentation bodies adopt the platform's governance | bodies of documentation hosted | 0 → ≥1 | existing bodies' usability unaffected | inspection at first onboarding |
|
||||||
|
| Bringing a body under governance is a decision, not a project | preparatory rearrangement / setup required | a flatten/rework → none | — | inspection at onboarding (PP-2) |
|
||||||
|
| Readers reliably reach documents in a large body | reader reaches the intended document | not feasible for structured bodies → routine | flat-body access unchanged | manual walkthrough (PP-1) |
|
||||||
|
|
||||||
|
> These are threshold/qualitative targets, not funnel metrics: this enables a new
|
||||||
|
> class of hosted documentation rather than tuning a conversion surface.
|
||||||
|
|
||||||
|
### 1.7 Scope (business)
|
||||||
|
|
||||||
|
- **In scope:** readers navigating a body by its own organization; contributors
|
||||||
|
proposing additions and changes anywhere in a body under review; stewards
|
||||||
|
bringing an existing body under governance without rearranging it.
|
||||||
|
- **Out of scope:** authoring tools beyond proposing/reviewing text documents;
|
||||||
|
governance policy changes (who may review/accept) — unchanged.
|
||||||
|
- **Non-goals:** becoming a general file store for non-document assets — the value
|
||||||
|
is governed *documents*, not arbitrary binaries.
|
||||||
|
|
||||||
|
### 1.8 Assumptions · Constraints · Dependencies
|
||||||
|
|
||||||
|
- **Assumptions:** bodies brought under governance are predominantly prose
|
||||||
|
documents (risk: a body that is mostly non-document assets gains little).
|
||||||
|
- **Constraints:** the existing collaborative-governance model (proposal →
|
||||||
|
review → acceptance) is reused, not redefined.
|
||||||
|
- **Dependencies:** the steward can grant the platform access to the existing
|
||||||
|
documentation body.
|
||||||
|
|
||||||
|
### 1.9 Business Use Cases
|
||||||
|
|
||||||
|
Solution-agnostic; no product, no technology.
|
||||||
|
|
||||||
|
**BUC-1 — As a reader, I can find and read a document by where it sits in the body, so that I get authoritative content without knowing an internal name.**
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: BUC-1 — A reader finds and reads a document in a structured body
|
||||||
|
Given a body of documentation organized into sections
|
||||||
|
When a reader looks for a particular document
|
||||||
|
Then they can locate it by where it sits in that organization
|
||||||
|
And read its current content
|
||||||
|
```
|
||||||
|
- **BUC-1 acceptance criteria:** the reader reaches the intended document and
|
||||||
|
reads its current authoritative content.
|
||||||
|
|
||||||
|
**BUC-1a — As a reader, when the body is briefly unavailable, I am still oriented and never hit a dead end.**
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: BUC-1a — The documentation is temporarily unavailable
|
||||||
|
Given the documentation cannot be reached for a moment
|
||||||
|
When a reader tries to access it
|
||||||
|
Then they are still shown what was last known to exist, or told clearly how to try again
|
||||||
|
And are never left at an empty, unexplained dead end
|
||||||
|
```
|
||||||
|
|
||||||
|
**BUC-2 — As a contributor, I can propose a change to a document, so that improvements are reviewed before they become authoritative.**
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: BUC-2 — A contributor proposes a change to a document
|
||||||
|
Given a contributor authorized to change the documentation
|
||||||
|
When they propose a change to an existing document
|
||||||
|
Then the change enters review before it can become authoritative
|
||||||
|
And the document is shown as having a change under review
|
||||||
|
```
|
||||||
|
- **BUC-2 acceptance criteria:** a reviewable proposal exists against that
|
||||||
|
document; until accepted, the authoritative content is unchanged.
|
||||||
|
|
||||||
|
**BUC-2a — As a contributor, when my proposal can't be accepted as placed, I'm told why and nothing is half-done.**
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: BUC-2a — A proposed change cannot be accepted as placed
|
||||||
|
Given a contributor proposing a document
|
||||||
|
When the chosen placement conflicts with an existing document, or is not allowed
|
||||||
|
Then the proposal is refused with a clear, specific reason
|
||||||
|
And nothing is partially recorded
|
||||||
|
```
|
||||||
|
|
||||||
|
**BUC-3 — As a contributor, I can introduce a new document anywhere in the body, so that the body grows where the content belongs.**
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: BUC-3 — A contributor introduces a new document anywhere in the body
|
||||||
|
Given a contributor authorized to add documentation
|
||||||
|
When they propose a new document at a place within the body
|
||||||
|
Then it enters review as a draft in that place
|
||||||
|
And on acceptance it becomes the authoritative document there
|
||||||
|
```
|
||||||
|
- **BUC-3 acceptance criteria:** a draft appears at the chosen place and is
|
||||||
|
reviewable; on acceptance it is the authoritative document there.
|
||||||
|
|
||||||
|
**BUC-4 — As a documentation steward, I can bring an existing body under collaborative governance, so that it gains review and shared maintenance without disruption.**
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: BUC-4 — A steward brings an existing doc body under governance
|
||||||
|
Given an organization with an existing body of documentation
|
||||||
|
When that documentation is brought under collaborative governance
|
||||||
|
Then all of its existing documents are immediately readable as authoritative
|
||||||
|
And none of them had to be rearranged to make that possible
|
||||||
|
```
|
||||||
|
- **BUC-4 acceptance criteria:** every existing document is readable and treated
|
||||||
|
as authoritative, with no rearrangement and no preparatory data entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Solution Proposal
|
||||||
|
|
||||||
|
Build software: make the platform's left-pane navigation a **universal
|
||||||
|
directory-tree** that mirrors a content repository's own git directory structure,
|
||||||
|
and address every document by its **path** rather than a flat slug — so a
|
||||||
|
documentation body is navigated by its real structure while keeping the existing
|
||||||
|
propose → review → accept governance on every file. The flat list becomes the
|
||||||
|
degenerate "one folder" case of the tree; a deep body is the general case. A new
|
||||||
|
`CorpusTree` left-pane component replaces the flat §7 Catalog as the *universal*
|
||||||
|
idiom (one navigation model for every project type), with **two display modes** —
|
||||||
|
*structure* (the directory tree) and *flat* (today's ranked list, engaged on
|
||||||
|
search or a non-path sort) — so the four affordances the flat list provides
|
||||||
|
(search, lifecycle state, sort, the contribution surface) all survive.
|
||||||
|
|
||||||
|
An existing body is onboarded as an ordinary registry project pointed at its
|
||||||
|
repository, with **no content migration and no state backfill**: a document
|
||||||
|
present with no lifecycle record is treated as authoritative ("no record =
|
||||||
|
active").
|
||||||
|
|
||||||
|
**Why this approach, over the alternatives:**
|
||||||
|
- *Do nothing / keep flat + rearrange repos* — rejected: PP-2 makes a rearrange a
|
||||||
|
non-starter for a living body.
|
||||||
|
- *A read-only documentation viewer* — rejected: delivers PP-1 but not PP-3
|
||||||
|
(no governed contribution).
|
||||||
|
- *A second, separate tree view beside the flat catalog* — rejected: forks the
|
||||||
|
navigation idiom and doubles the surface; the tree generalizes the flat list
|
||||||
|
rather than sitting beside it.
|
||||||
|
|
||||||
|
**Solution-specific scope / non-goals:**
|
||||||
|
- *In:* path-addressed documents; a directory-tree read surface; the dual-mode
|
||||||
|
pane; lifecycle by path; propose/edit/review at any path; zero-migration
|
||||||
|
onboarding; legacy slug→path URL compatibility.
|
||||||
|
- *Out:* lazy per-folder tree loading (a scale follow-on; v1 serves the full
|
||||||
|
document tree from cache); a rich renderer for non-document files (listed but
|
||||||
|
inert); changes to graduation / integer-ID assignment.
|
||||||
|
- *Non-goals:* merging folders with collections (kept distinct — INV-3); a new
|
||||||
|
project *type* (existing types render via the tree — D5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Product Personas
|
||||||
|
|
||||||
|
| Product persona | In rfc-app | Maps to business role(s) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Reader | Browses a project's corpus and opens documents | Reader |
|
||||||
|
| Contributor | Holds collection `can_contribute`; proposes/edits docs at any path | Contributor, Maintainer |
|
||||||
|
| Deployment operator | Registers a content repo as a project and flips the cutover flag | Documentation steward |
|
||||||
|
|
||||||
|
## 4. Product Use Cases
|
||||||
|
|
||||||
|
UX-level; steps are about the Product Personas (§3). Each links to the Business UC
|
||||||
|
it realizes.
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-1 — Browse the directory tree in structure mode (realizes BUC-1)
|
||||||
|
Given the left pane is in structure mode
|
||||||
|
When the reader opens the project
|
||||||
|
Then folders and files are shown in path order
|
||||||
|
And each markdown file shows its lifecycle-state badge
|
||||||
|
And folders expand and collapse, with expansion persisted across visits
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-2 — Search or non-path sort flips to flat mode (realizes BUC-1)
|
||||||
|
Given the pane is in structure mode
|
||||||
|
When the reader types a search query or picks a non-path sort
|
||||||
|
Then folders are hidden and a ranked flat list of matches is shown
|
||||||
|
And clearing the search with a path sort returns the tree with folders restored
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-3 — Open a document by its path (realizes BUC-1)
|
||||||
|
When the reader selects a file in the tree
|
||||||
|
Then the main column renders that document
|
||||||
|
And the tree highlights the corresponding node
|
||||||
|
And the document's lifecycle state is visible
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-4 — Propose a new doc at a path (realizes BUC-3)
|
||||||
|
Given a contributor with contribute capability
|
||||||
|
When they choose "Propose new doc" and give a path like deploy/runbooks/rollback.md
|
||||||
|
Then intermediate folders are created in the proposal branch
|
||||||
|
And a PR is opened via the bot
|
||||||
|
And a super-draft entry appears at that path
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-5 — Propose an edit to an existing doc (realizes BUC-2)
|
||||||
|
Given a contributor viewing an existing document
|
||||||
|
When they propose an edit
|
||||||
|
Then an edit PR is opened against that document's path
|
||||||
|
And the file's node shows the in-review badge with the open PR reference
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-6 — Filter and see lifecycle state (realizes BUC-1, BUC-2)
|
||||||
|
Given files exist in active, in-review, and super-draft states
|
||||||
|
When the reader enables the "in review" state filter
|
||||||
|
Then only files with an open edit PR are listed
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-7 — Star a doc; starred pins in flat mode (product-only)
|
||||||
|
Given the reader has starred a file
|
||||||
|
When the pane is in flat mode
|
||||||
|
Then the starred file is pinned above the ranked results
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-8 — Legacy slug URL redirects to its path (product-only, compat)
|
||||||
|
When the reader navigates to a legacy /p/:pid/c/:cid/e/:slug URL
|
||||||
|
Then they are redirected to the path-addressed URL for that entry
|
||||||
|
And the document renders
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-9 — Operator onboards an existing repo (realizes BUC-4)
|
||||||
|
Given an existing doc repo and bot read+write access
|
||||||
|
When the operator registers it as a project's content repo and flips the cutover flag
|
||||||
|
Then every pre-existing file renders as active in the tree
|
||||||
|
And no content was moved and no state was backfilled
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-10 — Tree fetch fails (realizes BUC-1a)
|
||||||
|
Given a previously cached tree exists
|
||||||
|
When the tree-listing fetch to gitea fails
|
||||||
|
Then the last good tree is served from cache
|
||||||
|
And when no cache exists, a graceful empty state with a retry action is shown
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-11 — Propose at an invalid path (realizes BUC-2a)
|
||||||
|
When a contributor proposes a doc at an existing or out-of-repo path
|
||||||
|
Then the proposal is rejected with a clear validation error
|
||||||
|
And no branch or PR is left behind
|
||||||
|
```
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: PUC-12 — Non-markdown files are listed but inert (product detail)
|
||||||
|
Given the repo contains schemas/app.json
|
||||||
|
When the tree is listed
|
||||||
|
Then schemas/app.json appears in the tree
|
||||||
|
But it has no lifecycle badge and opens no entry view
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. UX Layout
|
||||||
|
|
||||||
|
### 5.1 Left pane — `CorpusTree` (serves PUC-1..PUC-7, PUC-12)
|
||||||
|
|
||||||
|
- **Purpose:** navigate a project's corpus and reach any document; surface
|
||||||
|
lifecycle state and the contribution affordances.
|
||||||
|
- **Layout (top → bottom):**
|
||||||
|
- **Toolbar:** search input; lifecycle state filter-chips; sort selector
|
||||||
|
(path · recent · title · id · state); `+ Propose new doc` action.
|
||||||
|
- **Body — structure mode (default):** indented directory tree; folders with
|
||||||
|
expand/collapse in path order; file rows show a state dot
|
||||||
|
(active / in-review / super-draft) and a star marker; the active file is
|
||||||
|
highlighted. Non-markdown files render dimmed and inert.
|
||||||
|
- **Body — flat mode (search / non-path sort):** folders hidden; a ranked flat
|
||||||
|
list of file rows (path as secondary text); starred pinned to top; pending
|
||||||
|
super-drafts grouped.
|
||||||
|
- **States:** happy: tree/list rendered · empty: "No documents yet" + propose CTA
|
||||||
|
if permitted · loading: skeleton rows · error: stale tree if cached, else an
|
||||||
|
empty state with Retry · permission: propose control hidden without
|
||||||
|
`can_contribute`.
|
||||||
|
- **Notifications:** inline validation error on invalid propose (PUC-11); existing
|
||||||
|
PR/discussion notifications unchanged.
|
||||||
|
|
||||||
|
### 5.2 Main column (serves PUC-3, PUC-5)
|
||||||
|
|
||||||
|
Unchanged — rendered markdown + discussion + PR/contribution affordances —
|
||||||
|
addressed by path instead of slug.
|
||||||
|
|
||||||
|
## 6. Technical Design
|
||||||
|
|
||||||
|
### 6.1 Invariants
|
||||||
|
|
||||||
|
- **INV-1:** An entry is addressed by its repo `path` within a
|
||||||
|
`(project, collection)`. Slug addressing exists only as a legacy redirect.
|
||||||
|
- **INV-2:** A markdown file present on `main` with no explicit lifecycle record
|
||||||
|
is `active`. Onboarding requires no state backfill.
|
||||||
|
- **INV-3:** Folders are organizational only. Access control is the collection's;
|
||||||
|
a folder never carries permissions.
|
||||||
|
- **INV-4:** Lifecycle records are keyed by `(collection, path)` and follow file
|
||||||
|
renames on merge — no orphaned records survive a `git mv`.
|
||||||
|
- **INV-5:** The framework names no deployment. The backing repo is supplied via
|
||||||
|
the registry (`CLAUDE.md` separation-of-concerns).
|
||||||
|
- **INV-6:** Non-markdown files are listed but carry no lifecycle and open no
|
||||||
|
entry view.
|
||||||
|
- **INV-7:** The tree pane never renders blank — stale cache or an explicit
|
||||||
|
empty/retry state on fetch failure.
|
||||||
|
|
||||||
|
### 6.2 High-level architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
CT[CorpusTree pane] -->|GET tree / entry by path| API[API service]
|
||||||
|
CT -->|propose / edit at path| API
|
||||||
|
API --> CACHE[(TTL cache)]
|
||||||
|
CACHE --> GT[Gitea git-trees / raw]
|
||||||
|
API -->|branch / PR writes| BOT[Bot account]
|
||||||
|
BOT --> GT
|
||||||
|
API --> DB[(App DB: lifecycle, stars)]
|
||||||
|
REG[rfc-registry] -->|project to content-repo| API
|
||||||
|
WH[Gitea webhooks] -->|invalidate / reconcile| CACHE
|
||||||
|
```
|
||||||
|
|
||||||
|
- **CorpusTree** — owns left-pane rendering and mode state; owns no source of
|
||||||
|
truth; must never assume a flat namespace.
|
||||||
|
- **API service** — owns the tree-listing and path-addressed entry/contribution
|
||||||
|
endpoints; derives lifecycle state; must never write content except via the bot.
|
||||||
|
- **App DB** — owns lifecycle records, stars, reviewed-marks keyed by
|
||||||
|
`(collection, path)`; system of record for collaboration state.
|
||||||
|
- **Gitea** — system of record for document content and PRs.
|
||||||
|
- **Cache** — holds the materialized tree + per-path metadata; invalidated by the
|
||||||
|
webhook/PR-merge signals used today (§4.1 reconciler pattern).
|
||||||
|
|
||||||
|
### 6.3 Data model & ownership
|
||||||
|
|
||||||
|
| Entity | Owned by | Key fields | System of record |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Document (entry) | Gitea | `(project, collection, path)`, body | Gitea repo `main` |
|
||||||
|
| Lifecycle record | App DB | `(collection, path)`, state, reviewed-by | App DB |
|
||||||
|
| Star | App DB | `(user, collection, path)` | App DB |
|
||||||
|
| Open-PR mapping | Gitea | `path` → PR number | Gitea |
|
||||||
|
| Tree node (materialized) | Cache | `path`, type, `last_commit_at`, state, `open_pr`, `starred_by_me` | derived (cache) |
|
||||||
|
|
||||||
|
### 6.4 Interfaces & contracts
|
||||||
|
|
||||||
|
- **`GET /api/projects/{pid}/collections/{cid}/tree`** — in: pid, cid, viewer ·
|
||||||
|
out: nodes `{path, type, last_commit_at, lifecycle_state, open_pr,
|
||||||
|
starred_by_me}` (full markdown tree) · errors: `404` unknown project/collection;
|
||||||
|
gitea failure → stale cache or `503` with a retriable marker (INV-7).
|
||||||
|
- **Path-addressed entry read** — generalize `…/rfcs/{slug}` to a path
|
||||||
|
(`…/entries/{path}`); out: existing entry payload; errors: `404`.
|
||||||
|
- **Propose-at-path / edit / discussion** — generalize existing contribution/PR/
|
||||||
|
discussion endpoints from `slug` to `path`; propose-new in: target path, body;
|
||||||
|
out: PR ref; errors: `409` path exists, `422` invalid/out-of-repo path
|
||||||
|
(PUC-11), `403` no `can_contribute`.
|
||||||
|
|
||||||
|
### 6.5 Per–Product-Use-Case design
|
||||||
|
|
||||||
|
#### PUC-1 — Browse the tree (realizes BUC-1; honors INV-2, INV-7)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor R as Reader
|
||||||
|
participant CT as CorpusTree
|
||||||
|
participant A as API
|
||||||
|
participant C as Cache
|
||||||
|
participant G as Gitea
|
||||||
|
R->>CT: open project
|
||||||
|
CT->>A: GET …/tree
|
||||||
|
A->>C: get materialized tree
|
||||||
|
alt cache hit
|
||||||
|
C-->>A: tree
|
||||||
|
else miss
|
||||||
|
A->>G: git trees + last-commit
|
||||||
|
G-->>A: entries
|
||||||
|
A->>A: derive state (no record = active, INV-2)
|
||||||
|
A->>C: store
|
||||||
|
end
|
||||||
|
A-->>CT: nodes
|
||||||
|
CT-->>R: structure-mode tree (or stale/empty per INV-7)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Implementation:** materialize the markdown tree once per cache cycle; join
|
||||||
|
per-path lifecycle/star records; "no record = active." Folder expansion is
|
||||||
|
client state (localStorage keyed by project/collection).
|
||||||
|
|
||||||
|
#### PUC-4 — Propose a new doc at a path (realizes BUC-3; honors INV-1, INV-3)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor Cn as Contributor
|
||||||
|
participant CT as CorpusTree
|
||||||
|
participant A as API
|
||||||
|
participant B as Bot
|
||||||
|
participant G as Gitea
|
||||||
|
Cn->>CT: + Propose new doc (path)
|
||||||
|
CT->>A: propose-at-path(path, body)
|
||||||
|
A->>A: validate path (unique, in-repo) else 409/422
|
||||||
|
A->>B: create branch + file (mkdir -p path)
|
||||||
|
B->>G: commit + open PR
|
||||||
|
G-->>A: PR ref
|
||||||
|
A-->>CT: super-draft at path
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Implementation:** reuse the existing bot/branch/PR flow; only the target path
|
||||||
|
and intermediate-folder creation change. PUC-5 (edit), PUC-6 (filter), PUC-8
|
||||||
|
(redirect) reuse existing flows with `slug`→`path` substitution and need no new
|
||||||
|
sequence.
|
||||||
|
|
||||||
|
### 6.6 Non-functional requirements & cross-cutting concerns
|
||||||
|
|
||||||
|
- **Security & privacy:** read follows existing project/collection visibility;
|
||||||
|
propose gated by `can_contribute` (INV-3); all writes via the bot; secrets stay
|
||||||
|
references, never bytes (§6.3-handbook). No new PII.
|
||||||
|
- **Performance & scale:** v1 returns the full markdown tree from the TTL cache;
|
||||||
|
cold build is one git-trees call + a last-commit join. Lazy per-folder loading
|
||||||
|
is the scale follow-on if a body's tree is large enough to hurt cold-build
|
||||||
|
latency.
|
||||||
|
- **Availability & resilience:** stale-cache fallback + empty/retry state
|
||||||
|
(INV-7); no new hard dependency beyond what the corpus already needs.
|
||||||
|
- **Observability:** log tree cold-builds, cache hit/miss, gitea-failure
|
||||||
|
fallbacks; reuse existing health signals.
|
||||||
|
- **Accessibility:** the tree uses ARIA `tree`/`treeitem` roles with keyboard
|
||||||
|
expand/collapse and roving focus; state conveyed by text/label, not color alone.
|
||||||
|
|
||||||
|
### 6.7 Key decisions & alternatives considered
|
||||||
|
|
||||||
|
| Decision | Chosen | Alternatives considered | Why chosen |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| D1 Interaction model | Full governance lifecycle on any file | read-only viewer; read + light comments | operator intent; reuses existing machinery |
|
||||||
|
| D2 Topology | Tree becomes universal | tree as a second view; tree only for a new type | one navigation model; no idiom fork |
|
||||||
|
| D3 Entry identity | Path-addressed | keep slug + side table of paths | path is the natural key for a real body |
|
||||||
|
| D4 Folders vs collections | Distinct | merge folders into collections | collections are access-control, folders are structure |
|
||||||
|
| D5 Project type | No new type | a `tree`/`docs` type | "universal" means all types render via tree |
|
||||||
|
| D6 Build approach | New `CorpusTree`, port flat mode, retire Catalog | evolve Catalog in place; promote DocsLayout flyout | clean dual-mode boundary; safe staged cutover |
|
||||||
|
| D7 Existing files | "No record = active" | backfill a record per file at onboard | zero-migration onboarding |
|
||||||
|
| D8 Affordances | Keep all four via dual mode | drop sort/pending on a tree | operator marked all non-negotiable |
|
||||||
|
| D9 Routing | `e/*` splat + `?pr=` + legacy shim | encode path in slug; new `/f/` segment | least collision; reuses redirect pattern |
|
||||||
|
|
||||||
|
### 6.8 Testing strategy
|
||||||
|
|
||||||
|
Tests are part of each slice (not a follow-up).
|
||||||
|
|
||||||
|
- **Unit (vitest):** `CorpusTree` mode-switching (search→flat, sort→flat,
|
||||||
|
clear→structure), badge rendering, expand/collapse persistence, starred-pin,
|
||||||
|
flat-mode parity with the retired Catalog.
|
||||||
|
- **Backend (DI at boundaries):** tree endpoint state derivation (INV-2), open-PR
|
||||||
|
detection, path-addressed read/contribution, slug→path migration, rename
|
||||||
|
reconciliation (INV-4).
|
||||||
|
- **Two-tier:** Tier-1 local-Docker gitea for branch/PR flows; Tier-2 PPE for the
|
||||||
|
onboard-a-real-repo path (SLICE-4). e2e: browse → open; propose-at-path → PR →
|
||||||
|
merge → state transitions; search flatten; legacy redirect.
|
||||||
|
|
||||||
|
### 6.9 Failure modes, rollback & flags
|
||||||
|
|
||||||
|
- **Failure:** gitea unavailable → **behavior:** stale cache or empty/retry
|
||||||
|
(INV-7) → **rollback:** none needed.
|
||||||
|
- **Failure:** a project regresses on the new pane → **behavior/rollback:** flip
|
||||||
|
the **per-project-type cutover flag** off; the Catalog renders again (not
|
||||||
|
deleted until parity is proven).
|
||||||
|
- **Feature flag:** per-project-type cutover flag, default **off**; deployments
|
||||||
|
enable per project after validating parity.
|
||||||
|
|
||||||
|
## 7. Delivery Plan
|
||||||
|
|
||||||
|
### 7.1 Approach / strategy
|
||||||
|
|
||||||
|
Riskiest-foundation-first: land the path/state model and tree read behind the
|
||||||
|
existing flat UI (no user-visible change), then the dual-mode UI behind a flag,
|
||||||
|
then contribution-at-path, then onboard a real body. Per the handbook execution
|
||||||
|
convention, **each slice is its own coding session** — plan just-in-time →
|
||||||
|
execute → verify → ship → merge + version bump — in dependency order; this design
|
||||||
|
pass happens once and is amended only if a slice proves it wrong. The flag keeps
|
||||||
|
every intermediate state shippable.
|
||||||
|
|
||||||
|
### 7.2 Slicing plan
|
||||||
|
|
||||||
|
#### SLICE-1 — Backend foundation → completes (no user-visible PUC)
|
||||||
|
- **Depends on:** —
|
||||||
|
- **DoD:** path-keyed lifecycle + slug→path migration + "no record = active"
|
||||||
|
(INV-2) + tree endpoint + path-addressed reads; Catalog still renders; backend
|
||||||
|
tests green.
|
||||||
|
|
||||||
|
#### SLICE-2 — `CorpusTree` frontend → completes PUC-1, PUC-2, PUC-3, PUC-6, PUC-7, PUC-8, PUC-10, PUC-12
|
||||||
|
- **Depends on:** SLICE-1
|
||||||
|
- **DoD:** dual-mode pane behind the cutover flag; path routing + legacy shim;
|
||||||
|
flat-mode parity; Catalog retired once a project is flipped; unit + e2e green.
|
||||||
|
|
||||||
|
#### SLICE-3 — Contribution-at-path → completes PUC-4, PUC-5, PUC-11
|
||||||
|
- **Depends on:** SLICE-1
|
||||||
|
- **DoD:** propose/edit/discussion at arbitrary paths; rename reconciliation
|
||||||
|
(INV-4); validation (BUC-2a); tests green.
|
||||||
|
|
||||||
|
#### SLICE-4 — Onboard an existing doc body → completes PUC-9 (BUC-4)
|
||||||
|
- **Depends on:** SLICE-2, SLICE-3
|
||||||
|
- **DoD:** a real external repo registered + bot access + flag flipped; all
|
||||||
|
pre-existing files render active with zero migration; Tier-2 PPE validation.
|
||||||
|
|
||||||
|
### 7.3 Rollout / launch plan
|
||||||
|
|
||||||
|
Pre-v1, single-prod: each slice ships to prod on merge with its CHANGELOG upgrade
|
||||||
|
steps; the per-project-type flag defaults off, so deployments opt in per project.
|
||||||
|
Rollback trigger: flag off → Catalog returns (§6.9).
|
||||||
|
|
||||||
|
### 7.4 Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Likelihood / impact | Mitigation |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Full-tree fetch slow on a large body | M / M | TTL cache; lazy per-folder loading as a defined follow-on |
|
||||||
|
| Catalog flat-mode parity gaps after cutover | M / H | port logic verbatim; flag-gated per project; keep Catalog until parity proven |
|
||||||
|
| Rename leaves orphan state records | M / M | webhook reconciliation (INV-4) + test |
|
||||||
|
| slug→path migration mis-maps non-`document` entry folders | L / M | resolve entry folder per project type, not hardcoded; migration test |
|
||||||
|
|
||||||
|
## 8. Traceability matrix
|
||||||
|
|
||||||
|
| Pain | Business UC | Product UC | Slice | Tests |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| PP-1 | BUC-1 | PUC-1, PUC-2, PUC-3, PUC-6, PUC-7 | SLICE-2 | `test_tree_*`, `corpustree.*.test` |
|
||||||
|
| PP-4 | BUC-1a | PUC-10 | SLICE-2 | `test_tree_fallback_*` |
|
||||||
|
| PP-3 | BUC-2 | PUC-5, PUC-6 | SLICE-3 | `test_edit_at_path_*` |
|
||||||
|
| PP-3 | BUC-2a | PUC-11 | SLICE-3 | `test_propose_validation_*` |
|
||||||
|
| PP-3 | BUC-3 | PUC-4 | SLICE-3 | `test_propose_at_path_*` |
|
||||||
|
| PP-2 | BUC-4 | PUC-9 | SLICE-4 | e2e `onboard_repo_*` |
|
||||||
|
| PP-1 | (compat) | PUC-8 | SLICE-2 | `test_legacy_redirect_*` |
|
||||||
|
| PP-1 | (detail) | PUC-12 | SLICE-2 | `test_tree_non_markdown_*` |
|
||||||
|
|
||||||
|
## 9. Open Questions & Decisions log
|
||||||
|
|
||||||
|
**Open**
|
||||||
|
|
||||||
|
| # | Question | Owner | Blocks |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| — | none outstanding | — | — |
|
||||||
|
|
||||||
|
**Resolved**
|
||||||
|
|
||||||
|
| # | Decision | Resolution | Date |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| D1 | Interaction model | Full governance lifecycle on any file | 2026-06-06 |
|
||||||
|
| D2 | Topology | Tree becomes the universal left pane | 2026-06-06 |
|
||||||
|
| D3 | Entry identity | Path-addressed; slug is legacy-redirect-only | 2026-06-06 |
|
||||||
|
| D4 | Folders vs collections | Kept distinct | 2026-06-06 |
|
||||||
|
| D5 | Project type | No new type; existing types render via tree | 2026-06-06 |
|
||||||
|
| D6 | Build approach | New `CorpusTree`; port flat mode; retire Catalog | 2026-06-06 |
|
||||||
|
| D7 | Pre-existing files | "No record = active"; zero-migration onboarding | 2026-06-06 |
|
||||||
|
| D8 | Affordances | Keep all four via dual display mode | 2026-06-06 |
|
||||||
|
| D9 | Routing | `e/*` splat + `?pr=` + legacy shim | 2026-06-06 |
|
||||||
|
|
||||||
|
Deferred (not open): lazy per-folder tree loading; raw/preview view for
|
||||||
|
non-markdown files.
|
||||||
|
|
||||||
|
## 10. Glossary & References
|
||||||
|
|
||||||
|
- **Structure mode** — left pane showing the directory tree in path order.
|
||||||
|
- **Flat mode** — left pane showing a ranked flat list (search/non-path sort);
|
||||||
|
the retired Catalog's behavior.
|
||||||
|
- **Entry** — a markdown document, addressed by its repo path within a
|
||||||
|
`(project, collection)`.
|
||||||
|
- **Lifecycle record** — app-DB row keyed by `(collection, path)` carrying
|
||||||
|
non-default state; absence means `active`.
|
||||||
|
- **References:** `SPEC.md` §7 (catalog), §22 (three-tier), §8 (revision), §13
|
||||||
|
(graduation), §20 (versioning); `CLAUDE.md` (separation-of-concerns);
|
||||||
|
handbook `solution-design/GUIDE.md` (this doc's standard).
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { waitForLatestOtc, clearMailpit } from './mailpit.js'
|
||||||
|
|
||||||
|
// Sign in and leave the rfc_session cookie on the page's browser context
|
||||||
|
// (page.request shares the page context's cookie jar, so a subsequent
|
||||||
|
// page.goto is authenticated). Two paths, chosen by environment:
|
||||||
|
//
|
||||||
|
// * DEPLOYED (PPE): when E2E_TEST_AUTH_SECRET is set, use the gated
|
||||||
|
// `/auth/test/login` shortcut (v0.52.0). The deployed env has no
|
||||||
|
// Mailpit sink to read an OTC code from, so the suite presents the
|
||||||
|
// shared secret and the server mints an owner session for the one
|
||||||
|
// configured E2E_TEST_AUTH_EMAIL. See backend/app/main.py.
|
||||||
|
// * LOCAL (Tier-1 docker stack): no secret set → the original §6.2 OTC
|
||||||
|
// path through Mailpit. The Tier-1 backend-seed pre-grants
|
||||||
|
// e2e-owner@example.test as a deployment owner, so signing in as that
|
||||||
|
// address yields write access (SLICE-4/5).
|
||||||
|
//
|
||||||
|
// OWNER_EMAIL is the identity both paths sign in as: E2E_OWNER_EMAIL when
|
||||||
|
// set (PPE points it at E2E_TEST_AUTH_EMAIL), else the Tier-1 default.
|
||||||
|
export const OWNER_EMAIL =
|
||||||
|
process.env.E2E_OWNER_EMAIL || 'e2e-owner@example.test'
|
||||||
|
|
||||||
|
export async function signIn(page, email) {
|
||||||
|
const secret = process.env.E2E_TEST_AUTH_SECRET
|
||||||
|
if (secret) {
|
||||||
|
const r = await page.request.post('/auth/test/login', {
|
||||||
|
data: { email },
|
||||||
|
headers: { 'X-Test-Auth-Secret': secret },
|
||||||
|
})
|
||||||
|
if (!r.ok()) throw new Error(`test-auth login failed: ${r.status()} ${await r.text()}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local Tier-1 OTC path through Mailpit.
|
||||||
|
await clearMailpit()
|
||||||
|
const req = await page.request.post('/auth/otc/request', { data: { email } })
|
||||||
|
if (!req.ok()) throw new Error(`otc request failed: ${req.status()}`)
|
||||||
|
const code = await waitForLatestOtc(email)
|
||||||
|
const verify = await page.request.post('/auth/otc/verify', { data: { email, code } })
|
||||||
|
if (!verify.ok()) throw new Error(`otc verify failed: ${verify.status()}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Dismiss the cookie-consent banner if it's showing. It is fixed to the bottom
|
||||||
|
// of the viewport and intercepts pointer events over the catalog footer (where
|
||||||
|
// row-select checkboxes live), so tests that click there must clear it first.
|
||||||
|
export async function dismissCookies(page) {
|
||||||
|
const banner = page.locator('.cookie-consent-banner')
|
||||||
|
if (await banner.count()) {
|
||||||
|
await page.getByRole('button', { name: 'Save choice' }).click().catch(() => {})
|
||||||
|
await banner.waitFor({ state: 'hidden' }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { signIn, OWNER_EMAIL } from './lib/auth.js'
|
||||||
|
import { dismissCookies } from './lib/ui.js'
|
||||||
|
|
||||||
|
// §22.4a Configurable Collection Metadata — end-to-end browser coverage for the
|
||||||
|
// three UI slices, against the Tier-1 stack's faceted `bdd` collection (seeded
|
||||||
|
// in testing/seed-gitea.sh with a fields: schema of priority(enum) + tags, and
|
||||||
|
// three entries: checkout-guest [P0], checkout-returning [P1], search-facets [P0]).
|
||||||
|
//
|
||||||
|
// Tests run in order against a freshly-seeded stack: SLICE-3 reads first, then
|
||||||
|
// SLICE-4 edits `checkout-returning`, then SLICE-5 bulk-edits the two P0 entries
|
||||||
|
// (distinct rows — no cross-test interference within one run).
|
||||||
|
|
||||||
|
const BDD = '/p/ohm/c/bdd'
|
||||||
|
|
||||||
|
// SLICE-3 (PUC-3) — faceted left-pane filtering, anonymous/read-only.
|
||||||
|
test('SLICE-3: faceted filter narrows the catalog by Priority', async ({ page }) => {
|
||||||
|
await page.goto(BDD)
|
||||||
|
await dismissCookies(page)
|
||||||
|
const catalog = page.locator('aside.catalog')
|
||||||
|
|
||||||
|
// All three entries are listed initially.
|
||||||
|
await expect(catalog.getByText('Guest checkout')).toBeVisible()
|
||||||
|
await expect(catalog.getByText('Returning-customer checkout')).toBeVisible()
|
||||||
|
await expect(catalog.getByText('Faceted search')).toBeVisible()
|
||||||
|
|
||||||
|
// The Priority facet renders with the seeded P0 count (2 entries).
|
||||||
|
const p0 = catalog.locator('.facet-value', { hasText: 'P0' })
|
||||||
|
await expect(p0.locator('.facet-count')).toHaveText('2')
|
||||||
|
|
||||||
|
// Selecting P0 re-fetches server-side and drops the lone P1 entry.
|
||||||
|
await p0.getByRole('checkbox').check()
|
||||||
|
await expect(catalog.getByText('Returning-customer checkout')).toHaveCount(0)
|
||||||
|
await expect(catalog.getByText('Guest checkout')).toBeVisible()
|
||||||
|
await expect(catalog.getByText('Faceted search')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// SLICE-4 (PUC-1) — single-entry metadata edit via the detail panel (authed).
|
||||||
|
test('SLICE-4: edit one entry\'s priority from the detail panel', async ({ page }) => {
|
||||||
|
await signIn(page, OWNER_EMAIL)
|
||||||
|
await page.goto(`${BDD}/e/checkout-returning`)
|
||||||
|
await dismissCookies(page)
|
||||||
|
|
||||||
|
const panel = page.locator('.metadata-fields-panel')
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
|
// Seeded at P1; change to P2 and save (direct sidecar commit, D7).
|
||||||
|
await expect(panel.locator('#mf-priority')).toHaveValue('P1')
|
||||||
|
await panel.locator('#mf-priority').selectOption('P2')
|
||||||
|
await panel.getByRole('button', { name: 'Save' }).click()
|
||||||
|
await expect(panel.getByText('Saved')).toBeVisible()
|
||||||
|
|
||||||
|
// Persisted across a reload (read back from the committed sidecar).
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('.metadata-fields-panel #mf-priority')).toHaveValue('P2')
|
||||||
|
})
|
||||||
|
|
||||||
|
// SLICE-5 (PUC-2) — multi-select + bulk action bar, one commit (authed).
|
||||||
|
test('SLICE-5: bulk-set priority on multiple selected entries', async ({ page }) => {
|
||||||
|
await signIn(page, OWNER_EMAIL)
|
||||||
|
await page.goto(BDD)
|
||||||
|
await dismissCookies(page)
|
||||||
|
const catalog = page.locator('aside.catalog')
|
||||||
|
await expect(catalog.getByText('Guest checkout')).toBeVisible()
|
||||||
|
|
||||||
|
// Select the two P0 entries.
|
||||||
|
await catalog.getByLabel('select Guest checkout').check()
|
||||||
|
await catalog.getByLabel('select Faceted search').check()
|
||||||
|
|
||||||
|
// The sticky bulk bar appears with the selected count.
|
||||||
|
const bar = page.locator('.bulk-action-bar')
|
||||||
|
await expect(bar.getByText('2 selected')).toBeVisible()
|
||||||
|
|
||||||
|
// Set priority P1 across both → one commit; a toast reports the result.
|
||||||
|
await bar.getByLabel('Set Priority').selectOption('P1')
|
||||||
|
await expect(page.getByText(/2 updated/)).toBeVisible()
|
||||||
|
|
||||||
|
// The change is reflected server-side: the Priority P1 facet now counts the
|
||||||
|
// two newly-updated entries plus the pre-existing P1 (checkout-returning was
|
||||||
|
// P1 at seed; SLICE-4 may have moved it — assert at least the two we set).
|
||||||
|
await expect(catalog.locator('.facet-value', { hasText: 'P1' }).locator('.facet-count'))
|
||||||
|
.not.toHaveText('0')
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.46.1",
|
"version": "0.52.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -150,6 +150,56 @@
|
|||||||
.row-title { font-size: var(--text-base); margin-top: 2px; }
|
.row-title { font-size: var(--text-base); margin-top: 2px; }
|
||||||
.catalog-row.is-super .row-title { color: var(--c-gray-500); }
|
.catalog-row.is-super .row-title { color: var(--c-gray-500); }
|
||||||
.row-tags { font-size: var(--text-xs); color: var(--c-gray-500); margin-top: 2px; }
|
.row-tags { font-size: var(--text-xs); color: var(--c-gray-500); margin-top: 2px; }
|
||||||
|
.row-malformed { font-size: var(--text-2xs); color: var(--c-warning-accent); font-weight: 600; }
|
||||||
|
|
||||||
|
/* §22.4a SLICE-5 — row multi-select + bulk action bar (PUC-2, §5.3). */
|
||||||
|
.catalog-row-wrap { display: flex; align-items: flex-start; }
|
||||||
|
.catalog-row-wrap .row-select { margin: 10px 0 0 10px; flex: none; }
|
||||||
|
.catalog-row-wrap .catalog-row { flex: 1; min-width: 0; }
|
||||||
|
.bulk-action-bar {
|
||||||
|
position: sticky; top: 0; z-index: 2;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 14px; margin: 0;
|
||||||
|
background: var(--c-gray-150); border-bottom: 1px solid var(--c-gray-300);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.bulk-action-bar .bulk-count { font-weight: 700; }
|
||||||
|
.bulk-action-bar .bulk-set { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.bulk-action-bar .bulk-tags { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.bulk-action-bar .bulk-tags input { width: 90px; }
|
||||||
|
.bulk-action-bar .bulk-clear { margin-left: auto; }
|
||||||
|
|
||||||
|
/* §22.4a SLICE-3 — faceted left-pane filters (§5.1). */
|
||||||
|
.facets {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid var(--c-gray-150);
|
||||||
|
}
|
||||||
|
.facet-group { margin-bottom: 8px; }
|
||||||
|
.facet-header {
|
||||||
|
background: none; border: 0; cursor: pointer; width: 100%; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 700; color: var(--c-gray-600);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; padding: 4px 0;
|
||||||
|
}
|
||||||
|
.facet-values { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.facet-value {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: var(--text-xs); color: var(--c-gray-600); cursor: pointer;
|
||||||
|
}
|
||||||
|
.facet-value-label { flex: 1; }
|
||||||
|
.facet-count { color: var(--c-gray-500); font-variant-numeric: tabular-nums; }
|
||||||
|
.facet-value-search {
|
||||||
|
width: 100%; margin: 4px 0; font-size: var(--text-xs);
|
||||||
|
padding: 2px 6px; border: 1px solid var(--c-gray-150); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.facet-clear {
|
||||||
|
background: none; border: 0; cursor: pointer; padding: 0 0 6px;
|
||||||
|
font-size: var(--text-xs); color: var(--c-ink); text-decoration: underline;
|
||||||
|
}
|
||||||
|
.facet-empty { font-size: var(--text-xs); color: var(--c-gray-500); }
|
||||||
|
.facet-malformed {
|
||||||
|
display: flex; align-items: center; gap: 6px; margin-top: 6px;
|
||||||
|
font-size: var(--text-xs); color: var(--c-warning-accent); cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-pending {
|
.catalog-pending {
|
||||||
border-top: 1px solid var(--c-gray-150);
|
border-top: 1px solid var(--c-gray-150);
|
||||||
|
|||||||
+48
-4
@@ -203,11 +203,27 @@ export async function createProject({ projectId, name, type, visibility, content
|
|||||||
// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
|
// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
|
||||||
// collectionId, read the collection-scoped routes; with only a projectId, the
|
// collectionId, read the collection-scoped routes; with only a projectId, the
|
||||||
// project default-collection compat path; with neither, the unscoped path.
|
// project default-collection compat path; with neither, the unscoped path.
|
||||||
export async function listRFCs(projectId, collectionId) {
|
// §22.4a SLICE-3: faceted catalog. `opts.selections` is { field: string[] }
|
||||||
if (projectId && collectionId) {
|
// (each non-empty array becomes repeated query params, OR within the field);
|
||||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`))
|
// `opts.malformed` toggles the malformed-only filter. Returns the full payload
|
||||||
|
// { items, facets } — callers read both. With no selections it behaves as the
|
||||||
|
// pre-SLICE-3 list (and a no-fields collection returns facets: {}). Facets are
|
||||||
|
// served only on the project/collection-scoped paths, not the unscoped one.
|
||||||
|
export async function listRFCs(projectId, collectionId, opts = {}) {
|
||||||
|
const { selections = {}, malformed = false } = opts
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
for (const [field, values] of Object.entries(selections)) {
|
||||||
|
for (const v of values) qs.append(field, v)
|
||||||
}
|
}
|
||||||
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
if (malformed) qs.set('malformed', 'true')
|
||||||
|
const query = qs.toString()
|
||||||
|
let base
|
||||||
|
if (projectId && collectionId) {
|
||||||
|
base = `/api/projects/${projectId}/collections/${collectionId}/rfcs`
|
||||||
|
} else {
|
||||||
|
base = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||||
|
}
|
||||||
|
const url = query ? `${base}?${query}` : base
|
||||||
return jsonOrThrow(await fetch(url))
|
return jsonOrThrow(await fetch(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +682,34 @@ export async function editMetadata(slug, { title, tags, prDescription }) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §22.4a SLICE-4: edit one entry's schema-defined metadata — a direct commit
|
||||||
|
// to its `<slug>.meta.yaml` sidecar (contributor+ gated server-side).
|
||||||
|
export async function saveEntryMeta(projectId, collectionId, slug, values) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/meta`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ values }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// §22.4a SLICE-5 (PUC-2): apply one field op (set | add | remove) to many
|
||||||
|
// entries at once — one commit server-side; returns { applied, rejected }.
|
||||||
|
export async function bulkEntryMeta(projectId, collectionId, { slugs, op, field, value }) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/projects/${projectId}/collections/${collectionId}/meta/bulk`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slugs, op, field, value }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
|
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
|
||||||
|
|
||||||
export async function claimOwnership(slug) {
|
export async function claimOwnership(slug) {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// §22.4a SLICE-5 (PUC-2, UX §5.3): the sticky bulk action bar shown when ≥1
|
||||||
|
// catalog row is selected. Driven by the collection `fields:` schema — one
|
||||||
|
// "Set <field>" control per enum field, and an Add/Remove tag control per
|
||||||
|
// tags field. Each gesture calls onApply({ op, field, value }); the parent
|
||||||
|
// (Catalog) sends one bulk request and re-fetches.
|
||||||
|
|
||||||
|
const labelFor = (name, def) =>
|
||||||
|
def?.label || name.charAt(0).toUpperCase() + name.slice(1)
|
||||||
|
|
||||||
|
export default function BulkActionBar({ fields, count, onApply, onClear }) {
|
||||||
|
const [tagValue, setTagValue] = useState('')
|
||||||
|
const entries = Object.entries(fields || {})
|
||||||
|
const tagField = entries.find(([, d]) => d.type === 'tags')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bulk-action-bar">
|
||||||
|
<span className="bulk-count">{count} selected</span>
|
||||||
|
{entries
|
||||||
|
.filter(([, d]) => d.type === 'enum')
|
||||||
|
.map(([name, def]) => (
|
||||||
|
<label key={name} className="bulk-set">
|
||||||
|
<span>Set {labelFor(name, def)}</span>
|
||||||
|
<select
|
||||||
|
aria-label={`Set ${labelFor(name, def)}`}
|
||||||
|
value=""
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.value) onApply({ op: 'set', field: name, value: e.target.value })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{tagField && (
|
||||||
|
<div className="bulk-tags">
|
||||||
|
<input
|
||||||
|
placeholder="tag…"
|
||||||
|
value={tagValue}
|
||||||
|
onChange={e => setTagValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!tagValue.trim()}
|
||||||
|
onClick={() => { onApply({ op: 'add', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
|
||||||
|
>
|
||||||
|
Add tag
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!tagValue.trim()}
|
||||||
|
onClick={() => { onApply({ op: 'remove', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
|
||||||
|
>
|
||||||
|
Remove tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="bulk-clear" onClick={onClear}>Clear</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import BulkActionBar from './BulkActionBar.jsx'
|
||||||
|
|
||||||
|
const FIELDS = {
|
||||||
|
priority: { type: 'enum', values: ['P0', 'P1', 'P2'], label: 'Priority' },
|
||||||
|
tags: { type: 'tags', label: 'Tags' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BulkActionBar', () => {
|
||||||
|
it('shows the selected count', () => {
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={3} onApply={() => {}} onClear={() => {}} />)
|
||||||
|
expect(screen.getByText(/3 selected/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a set op when an enum value is chosen', () => {
|
||||||
|
const onApply = vi.fn()
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||||
|
fireEvent.change(screen.getByLabelText(/set priority/i), { target: { value: 'P0' } })
|
||||||
|
expect(onApply).toHaveBeenCalledWith({ op: 'set', field: 'priority', value: 'P0' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies an add-tag op', () => {
|
||||||
|
const onApply = vi.fn()
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/tag…/i), { target: { value: 'checkout' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add tag/i }))
|
||||||
|
expect(onApply).toHaveBeenCalledWith({ op: 'add', field: 'tags', value: 'checkout' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a remove-tag op', () => {
|
||||||
|
const onApply = vi.fn()
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/tag…/i), { target: { value: 'checkout' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /remove tag/i }))
|
||||||
|
expect(onApply).toHaveBeenCalledWith({ op: 'remove', field: 'tags', value: 'checkout' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClear', () => {
|
||||||
|
const onClear = vi.fn()
|
||||||
|
render(<BulkActionBar fields={FIELDS} count={2} onApply={() => {}} onClear={onClear} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /clear/i }))
|
||||||
|
expect(onClear).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,9 +8,12 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { listRFCs, listProposals, getCollection } from '../api'
|
import { listRFCs, listProposals, getCollection, bulkEntryMeta } from '../api'
|
||||||
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
||||||
import JoinRequestModal from './JoinRequestModal.jsx'
|
import JoinRequestModal from './JoinRequestModal.jsx'
|
||||||
|
import FacetGroups from './FacetGroups.jsx'
|
||||||
|
import BulkActionBar from './BulkActionBar.jsx'
|
||||||
|
import { showToast } from './ToastHost.jsx'
|
||||||
|
|
||||||
const STATE_CHIPS = [
|
const STATE_CHIPS = [
|
||||||
{ id: 'super-draft', label: 'Super-draft' },
|
{ id: 'super-draft', label: 'Super-draft' },
|
||||||
@@ -43,34 +46,81 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
const [sort, setSort] = useState('recent')
|
const [sort, setSort] = useState('recent')
|
||||||
const [activeChips, setActiveChips] = useState(new Set())
|
const [activeChips, setActiveChips] = useState(new Set())
|
||||||
const [pendingOpen, setPendingOpen] = useState(true)
|
const [pendingOpen, setPendingOpen] = useState(true)
|
||||||
|
// §22.4a SLICE-3: faceted left pane. When the collection declares a `fields:`
|
||||||
|
// schema, the catalog renders faceted groups (counts from the server) instead
|
||||||
|
// of the legacy state chips, and re-fetches server-side as selections change.
|
||||||
|
const [fields, setFields] = useState(null) // collection field schema
|
||||||
|
const [facets, setFacets] = useState({}) // { field: { value: count } }
|
||||||
|
const [selections, setSelections] = useState({}) // { field: Set<string> }
|
||||||
|
const [malformedOnly, setMalformedOnly] = useState(false)
|
||||||
|
// §22.4a SLICE-5: multi-select for the bulk action bar (PUC-2). A Set of
|
||||||
|
// selected slugs; the sticky bar appears when ≥1 is selected (faceted +
|
||||||
|
// contributor only). Cleared on collection switch and after a bulk apply.
|
||||||
|
const [selected, setSelected] = useState(() => new Set())
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
const { slug, prNumber } = useParams()
|
const { slug, prNumber } = useParams()
|
||||||
const pid = useProjectId()
|
const pid = useProjectId()
|
||||||
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
||||||
// route segment, else the project's default collection).
|
// route segment, else the project's default collection).
|
||||||
const cid = useCollectionId()
|
const cid = useCollectionId()
|
||||||
|
|
||||||
|
// Collection-level data (proposals, caps, field schema) loads once per
|
||||||
|
// collection — independent of the facet selections, which only re-fetch the
|
||||||
|
// entry list. Switching collections clears any active facet selections.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
|
||||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||||
setCanContribute(null)
|
setCanContribute(null)
|
||||||
setCanRequestJoin(false)
|
setCanRequestJoin(false)
|
||||||
|
setSelections({})
|
||||||
|
setMalformedOnly(false)
|
||||||
|
setSelected(new Set())
|
||||||
getCollection(pid, cid)
|
getCollection(pid, cid)
|
||||||
.then(c => {
|
.then(c => {
|
||||||
setCanContribute(!!c?.viewer?.can_contribute)
|
setCanContribute(!!c?.viewer?.can_contribute)
|
||||||
setCanRequestJoin(!!c?.viewer?.can_request_join)
|
setCanRequestJoin(!!c?.viewer?.can_request_join)
|
||||||
setEntryNoun(c?.entry_noun || 'RFC')
|
setEntryNoun(c?.entry_noun || 'RFC')
|
||||||
|
setFields(c?.fields || null)
|
||||||
})
|
})
|
||||||
.catch(() => setCanContribute(false))
|
.catch(() => { setCanContribute(false); setFields(null) })
|
||||||
}, [version, pid, cid])
|
}, [version, pid, cid])
|
||||||
|
|
||||||
|
// §22.4a SLICE-3: the entry list re-fetches server-side whenever the facet
|
||||||
|
// selections or the malformed toggle change (in legacy mode selections stay
|
||||||
|
// empty, so this fires once per collection like before).
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
const selObj = Object.fromEntries(
|
||||||
|
Object.entries(selections).map(([f, set]) => [f, [...set]])
|
||||||
|
)
|
||||||
|
listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly })
|
||||||
|
.then(d => {
|
||||||
|
setRfcs(d.items); setFacets(d.facets || {})
|
||||||
|
// §22.4a SLICE-5: drop any bulk selections that the new (filtered)
|
||||||
|
// list no longer contains, so the bar can't act on hidden rows.
|
||||||
|
const visible = new Set(d.items.map(it => it.slug))
|
||||||
|
setSelected(prev => {
|
||||||
|
const kept = [...prev].filter(s => visible.has(s))
|
||||||
|
return kept.length === prev.size ? prev : new Set(kept)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => { setRfcs([]); setFacets({}) })
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [version, pid, cid, selections, malformedOnly])
|
||||||
|
|
||||||
// While caps load, fall back to "any authenticated viewer" so the propose
|
// While caps load, fall back to "any authenticated viewer" so the propose
|
||||||
// affordance on the common (default-collection) case doesn't flash off.
|
// affordance on the common (default-collection) case doesn't flash off.
|
||||||
const mayPropose = canContribute === null ? !!viewer : canContribute
|
const mayPropose = canContribute === null ? !!viewer : canContribute
|
||||||
|
|
||||||
|
// §22.4a SLICE-3: faceted mode when the collection declares a non-empty
|
||||||
|
// `fields:` schema; otherwise the legacy state-chip catalog (INV-5).
|
||||||
|
const faceted = !!fields && Object.keys(fields).length > 0
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const needle = search.trim().toLowerCase()
|
const needle = search.trim().toLowerCase()
|
||||||
let items = rfcs.filter(r => {
|
let items = rfcs.filter(r => {
|
||||||
if (activeChips.size > 0 && !activeChips.has(r.state)) return false
|
// In faceted mode the server already filtered by state; the legacy chips
|
||||||
|
// narrow client-side only when there's no schema.
|
||||||
|
if (!faceted && activeChips.size > 0 && !activeChips.has(r.state)) return false
|
||||||
if (!needle) return true
|
if (!needle) return true
|
||||||
const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
|
const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
|
||||||
return hay.includes(needle)
|
return hay.includes(needle)
|
||||||
@@ -85,7 +135,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
return (b.last_active_at || '').localeCompare(a.last_active_at || '')
|
return (b.last_active_at || '').localeCompare(a.last_active_at || '')
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
}, [rfcs, search, sort, activeChips])
|
}, [rfcs, search, sort, activeChips, faceted])
|
||||||
|
|
||||||
function toggleChip(id) {
|
function toggleChip(id) {
|
||||||
const next = new Set(activeChips)
|
const next = new Set(activeChips)
|
||||||
@@ -93,6 +143,53 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
setActiveChips(next)
|
setActiveChips(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §22.4a SLICE-3 facet selection handlers (faceted mode).
|
||||||
|
function toggleFacet(field, value) {
|
||||||
|
setSelections(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
const set = new Set(next[field] || [])
|
||||||
|
set.has(value) ? set.delete(value) : set.add(value)
|
||||||
|
if (set.size === 0) delete next[field]
|
||||||
|
else next[field] = set
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function clearFilters() { setSelections({}); setMalformedOnly(false) }
|
||||||
|
const hasActiveFilters = Object.keys(selections).length > 0 || malformedOnly
|
||||||
|
|
||||||
|
// §22.4a SLICE-5: row selection + bulk apply (PUC-2).
|
||||||
|
function toggleSelect(rowSlug) {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(rowSlug) ? next.delete(rowSlug) : next.add(rowSlug)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBulk({ op, field, value }) {
|
||||||
|
const slugs = [...selected]
|
||||||
|
if (slugs.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await bulkEntryMeta(pid, cid, { slugs, op, field, value })
|
||||||
|
const nApplied = res.applied?.length || 0
|
||||||
|
const nRejected = res.rejected?.length || 0
|
||||||
|
showToast({
|
||||||
|
summary: nRejected
|
||||||
|
? `${nApplied} updated, ${nRejected} skipped`
|
||||||
|
: `${nApplied} updated`,
|
||||||
|
category: nRejected ? 'warning' : 'success',
|
||||||
|
})
|
||||||
|
setSelected(new Set())
|
||||||
|
// Re-fetch the list so the new values + facet counts reflect the change.
|
||||||
|
const selObj = Object.fromEntries(
|
||||||
|
Object.entries(selections).map(([f, set]) => [f, [...set]]))
|
||||||
|
const d = await listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly })
|
||||||
|
setRfcs(d.items); setFacets(d.facets || {})
|
||||||
|
} catch (e) {
|
||||||
|
showToast({ summary: e.message || 'Bulk update failed', category: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="catalog">
|
<aside className="catalog">
|
||||||
<div className="catalog-search">
|
<div className="catalog-search">
|
||||||
@@ -108,22 +205,54 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="catalog-chips">
|
{faceted ? (
|
||||||
{STATE_CHIPS.map(chip => (
|
<FacetGroups
|
||||||
<button
|
facets={facets}
|
||||||
key={chip.id}
|
fields={fields}
|
||||||
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
selections={selections}
|
||||||
onClick={() => toggleChip(chip.id)}
|
onToggle={toggleFacet}
|
||||||
>
|
malformedOnly={malformedOnly}
|
||||||
{chip.label}
|
onToggleMalformed={() => setMalformedOnly(m => !m)}
|
||||||
</button>
|
onClear={clearFilters}
|
||||||
))}
|
hasActiveFilters={hasActiveFilters}
|
||||||
</div>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="catalog-chips">
|
||||||
|
{STATE_CHIPS.map(chip => (
|
||||||
|
<button
|
||||||
|
key={chip.id}
|
||||||
|
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleChip(chip.id)}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{faceted && canContribute && selected.size > 0 && (
|
||||||
|
<BulkActionBar
|
||||||
|
fields={fields}
|
||||||
|
count={selected.size}
|
||||||
|
onApply={applyBulk}
|
||||||
|
onClear={() => setSelected(new Set())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="catalog-list">
|
<div className="catalog-list">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||||
{rfcs.length === 0
|
{loading
|
||||||
|
? 'Loading…'
|
||||||
|
// §22.4a SLICE-3: faceted mode with active filters → an
|
||||||
|
// explicitly-clearable "no entries match" state (§5.1).
|
||||||
|
: faceted && hasActiveFilters ? (
|
||||||
|
<>
|
||||||
|
No entries match.{' '}
|
||||||
|
<button className="facet-clear" onClick={clearFilters}>Clear filters</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: rfcs.length === 0
|
||||||
// C3.5: a contributor sees a propose-first call to action; a
|
// C3.5: a contributor sees a propose-first call to action; a
|
||||||
// granted viewer without contribute rights sees a bare empty
|
// granted viewer without contribute rights sees a bare empty
|
||||||
// state; an anonymous reader sees the read-only note (the footer
|
// state; an anonymous reader sees the read-only note (the footer
|
||||||
@@ -137,7 +266,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
filtered.map(r => {
|
filtered.map(r => {
|
||||||
const isActive = slug === r.slug
|
const isActive = slug === r.slug
|
||||||
const isSuper = r.state === 'super-draft'
|
const isSuper = r.state === 'super-draft'
|
||||||
return (
|
// §22.4a SLICE-5: selectable rows in faceted mode for contributors.
|
||||||
|
const selectable = faceted && canContribute
|
||||||
|
const row = (
|
||||||
<Link
|
<Link
|
||||||
key={r.slug}
|
key={r.slug}
|
||||||
to={entryPath(pid, r.slug, cid)}
|
to={entryPath(pid, r.slug, cid)}
|
||||||
@@ -147,6 +278,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
<span className={`row-id ${isSuper ? 'super' : ''}`}>
|
<span className={`row-id ${isSuper ? 'super' : ''}`}>
|
||||||
{isSuper ? 'super-draft' : (r.id || '—')}
|
{isSuper ? 'super-draft' : (r.id || '—')}
|
||||||
</span>
|
</span>
|
||||||
|
{r.metadata_malformed && (
|
||||||
|
<span className="row-malformed" title="Metadata fails this collection's schema">⚠ malformed</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="row-title">{r.title}</span>
|
<span className="row-title">{r.title}</span>
|
||||||
{r.tags.length > 0 && (
|
{r.tags.length > 0 && (
|
||||||
@@ -154,6 +288,19 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
if (!selectable) return row
|
||||||
|
return (
|
||||||
|
<div key={r.slug} className="catalog-row-wrap selectable">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="row-select"
|
||||||
|
aria-label={`select ${r.title}`}
|
||||||
|
checked={selected.has(r.slug)}
|
||||||
|
onChange={() => toggleSelect(r.slug)}
|
||||||
|
/>
|
||||||
|
{row}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
// §22.4a SLICE-3 — the faceted catalog pane (PUC-3). Mock the api so
|
||||||
|
// getCollection drives the field schema and listRFCs returns { items, facets }.
|
||||||
|
const listRFCs = vi.fn()
|
||||||
|
const getCollection = vi.fn()
|
||||||
|
const bulkEntryMeta = vi.fn()
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
listRFCs: (...a) => listRFCs(...a),
|
||||||
|
listProposals: () => Promise.resolve({ items: [] }),
|
||||||
|
getCollection: (...a) => getCollection(...a),
|
||||||
|
bulkEntryMeta: (...a) => bulkEntryMeta(...a),
|
||||||
|
}))
|
||||||
|
vi.mock('./ToastHost.jsx', () => ({ showToast: vi.fn() }))
|
||||||
|
vi.mock('../lib/entryPaths', () => ({
|
||||||
|
entryPath: () => '/x', proposalPath: () => '/p',
|
||||||
|
useProjectId: () => 'ohm', useCollectionId: () => 'default',
|
||||||
|
}))
|
||||||
|
import Catalog from './Catalog.jsx'
|
||||||
|
|
||||||
|
const renderCatalog = () =>
|
||||||
|
render(<MemoryRouter><Catalog viewer={null} onProposeRFC={() => {}} version={0} /></MemoryRouter>)
|
||||||
|
|
||||||
|
describe('Catalog faceted pane', () => {
|
||||||
|
beforeEach(() => { listRFCs.mockReset(); getCollection.mockReset(); bulkEntryMeta.mockReset() })
|
||||||
|
|
||||||
|
it('renders facet groups with counts when the collection declares fields', async () => {
|
||||||
|
getCollection.mockResolvedValue({
|
||||||
|
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||||
|
viewer: { can_contribute: false }, entry_noun: 'RFC',
|
||||||
|
})
|
||||||
|
listRFCs.mockResolvedValue({
|
||||||
|
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||||
|
facets: { priority: { P0: 3, P1: 1 }, state: { active: 4 } },
|
||||||
|
})
|
||||||
|
renderCatalog()
|
||||||
|
expect(await screen.findByText('P0')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-fetches with the selection when a facet checkbox is toggled', async () => {
|
||||||
|
getCollection.mockResolvedValue({
|
||||||
|
fields: { priority: { type: 'enum', values: ['P0'] } },
|
||||||
|
viewer: {}, entry_noun: 'RFC',
|
||||||
|
})
|
||||||
|
listRFCs.mockResolvedValue({ items: [], facets: { priority: { P0: 2 }, state: {} } })
|
||||||
|
renderCatalog()
|
||||||
|
const box = await screen.findByText('P0')
|
||||||
|
fireEvent.click(box.closest('label').querySelector('input'))
|
||||||
|
await waitFor(() => {
|
||||||
|
const last = listRFCs.mock.calls.at(-1)
|
||||||
|
expect(last[2].selections.priority).toContain('P0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reveals the bulk action bar when a row is selected (faceted + contributor)', async () => {
|
||||||
|
getCollection.mockResolvedValue({
|
||||||
|
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||||
|
viewer: { can_contribute: true }, entry_noun: 'RFC',
|
||||||
|
})
|
||||||
|
listRFCs.mockResolvedValue({
|
||||||
|
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||||
|
facets: { priority: { P0: 1 }, state: { active: 1 } },
|
||||||
|
})
|
||||||
|
renderCatalog()
|
||||||
|
const checkbox = await screen.findByLabelText(/select A/i)
|
||||||
|
fireEvent.click(checkbox)
|
||||||
|
expect(await screen.findByText(/1 selected/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends a bulk request and re-fetches when a set op is applied', async () => {
|
||||||
|
getCollection.mockResolvedValue({
|
||||||
|
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||||
|
viewer: { can_contribute: true }, entry_noun: 'RFC',
|
||||||
|
})
|
||||||
|
listRFCs.mockResolvedValue({
|
||||||
|
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||||
|
facets: { priority: { P0: 1 }, state: { active: 1 } },
|
||||||
|
})
|
||||||
|
bulkEntryMeta.mockResolvedValue({ applied: ['a'], rejected: [], committed: true })
|
||||||
|
renderCatalog()
|
||||||
|
fireEvent.click(await screen.findByLabelText(/select A/i))
|
||||||
|
fireEvent.change(await screen.findByLabelText(/set priority/i), { target: { value: 'P1' } })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(bulkEntryMeta).toHaveBeenCalledWith('ohm', 'default',
|
||||||
|
{ slugs: ['a'], op: 'set', field: 'priority', value: 'P1' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a selection when a filter change hides the row', async () => {
|
||||||
|
getCollection.mockResolvedValue({
|
||||||
|
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||||
|
viewer: { can_contribute: true }, entry_noun: 'RFC',
|
||||||
|
})
|
||||||
|
// Content-driven: unfiltered → row "a"; once the P0 facet is selected the
|
||||||
|
// server response omits "a" (drives the prune). Robust to extra mount-time
|
||||||
|
// fetches the collection effect triggers.
|
||||||
|
listRFCs.mockImplementation((p, c, opts) => Promise.resolve({
|
||||||
|
items: opts?.selections?.priority?.includes('P0')
|
||||||
|
? [{ slug: 'b', title: 'B', state: 'active', tags: [], starred_by_me: false }]
|
||||||
|
: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||||
|
facets: { priority: { P0: 1 }, state: { active: 1 } },
|
||||||
|
}))
|
||||||
|
const { container } = renderCatalog()
|
||||||
|
fireEvent.click(await screen.findByLabelText(/select A/i))
|
||||||
|
expect(await screen.findByText(/1 selected/i)).toBeInTheDocument()
|
||||||
|
// Toggle the P0 facet checkbox → re-fetch returns a list without "a".
|
||||||
|
fireEvent.click(container.querySelector('.facet-group input[type="checkbox"]'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/1 selected/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not offer row selection for a non-contributor', async () => {
|
||||||
|
getCollection.mockResolvedValue({
|
||||||
|
fields: { priority: { type: 'enum', values: ['P0'] } },
|
||||||
|
viewer: { can_contribute: false }, entry_noun: 'RFC',
|
||||||
|
})
|
||||||
|
listRFCs.mockResolvedValue({
|
||||||
|
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||||
|
facets: { priority: { P0: 1 }, state: { active: 1 } },
|
||||||
|
})
|
||||||
|
renderCatalog()
|
||||||
|
await screen.findByText('A')
|
||||||
|
expect(screen.queryByLabelText(/select A/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders legacy state chips when the collection declares no fields', async () => {
|
||||||
|
getCollection.mockResolvedValue({ fields: null, viewer: {}, entry_noun: 'RFC' })
|
||||||
|
listRFCs.mockResolvedValue({ items: [], facets: {} })
|
||||||
|
renderCatalog()
|
||||||
|
expect(await screen.findByText('Super-draft')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// FacetGroups.jsx — §22.4a SLICE-3 faceted left-pane filters (§5.1).
|
||||||
|
//
|
||||||
|
// Renders one collapsible group per facet field returned by the API, each with
|
||||||
|
// per-value result counts and multi-select checkboxes. A `tags`-type field gets
|
||||||
|
// a "filter values…" search box so it stays usable at 30+ values. Selection
|
||||||
|
// state + the malformed toggle are owned by the parent (Catalog), which
|
||||||
|
// re-fetches server-side on change.
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// Human label for the built-in state facet; declared fields use their own name
|
||||||
|
// (or the schema `label` when the API supplies it on `fields`).
|
||||||
|
function groupLabel(field, fields) {
|
||||||
|
if (field === 'state') return 'State'
|
||||||
|
const def = fields?.[field]
|
||||||
|
return def?.label || field.charAt(0).toUpperCase() + field.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FacetGroup({ field, fields, counts, selected, onToggle, isTags }) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const [valueSearch, setValueSearch] = useState('')
|
||||||
|
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1])
|
||||||
|
const needle = valueSearch.trim().toLowerCase()
|
||||||
|
const shown = isTags && needle
|
||||||
|
? entries.filter(([v]) => v.toLowerCase().includes(needle))
|
||||||
|
: entries
|
||||||
|
return (
|
||||||
|
<div className="facet-group">
|
||||||
|
<button className="facet-header" onClick={() => setOpen(o => !o)}>
|
||||||
|
<span>{open ? '▾' : '▸'} {groupLabel(field, fields)}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="facet-values">
|
||||||
|
{isTags && entries.length > 8 && (
|
||||||
|
<input
|
||||||
|
className="facet-value-search"
|
||||||
|
placeholder="filter values…"
|
||||||
|
value={valueSearch}
|
||||||
|
onChange={e => setValueSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{shown.length === 0 && (
|
||||||
|
<div className="facet-empty">No values.</div>
|
||||||
|
)}
|
||||||
|
{shown.map(([value, count]) => (
|
||||||
|
<label key={value} className="facet-value">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(value)}
|
||||||
|
onChange={() => onToggle(field, value)}
|
||||||
|
/>
|
||||||
|
<span className="facet-value-label">{value}</span>
|
||||||
|
<span className="facet-count">{count}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FacetGroups({
|
||||||
|
facets, fields, selections, onToggle, malformedOnly, onToggleMalformed,
|
||||||
|
onClear, hasActiveFilters,
|
||||||
|
}) {
|
||||||
|
// The API returns facets in field order with `state` last; render in that
|
||||||
|
// order. `selections` is { field: Set<string> }.
|
||||||
|
const fieldOrder = Object.keys(facets || {})
|
||||||
|
// A `tags`-type field renders the value-search box.
|
||||||
|
const isTags = (field) => fields?.[field]?.type === 'tags'
|
||||||
|
return (
|
||||||
|
<div className="facets">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button className="facet-clear" onClick={onClear}>Clear filters</button>
|
||||||
|
)}
|
||||||
|
{fieldOrder.map(field => (
|
||||||
|
<FacetGroup
|
||||||
|
key={field}
|
||||||
|
field={field}
|
||||||
|
fields={fields}
|
||||||
|
counts={facets[field]}
|
||||||
|
selected={selections[field] || new Set()}
|
||||||
|
onToggle={onToggle}
|
||||||
|
isTags={isTags(field)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<label className="facet-malformed">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={malformedOnly}
|
||||||
|
onChange={onToggleMalformed}
|
||||||
|
/>
|
||||||
|
<span>Malformed metadata only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { saveEntryMeta } from '../api'
|
||||||
|
|
||||||
|
// §22.4a SLICE-4 (PUC-1, UX §5.2): a schema-driven view/edit panel for one
|
||||||
|
// entry's metadata. One control per declared field — enum → single-select,
|
||||||
|
// tags → removable chips + add-input, text → text input. Authorized users
|
||||||
|
// (canEdit) save changed values via a direct sidecar commit; the document body
|
||||||
|
// renders separately as pure prose. A collection with no `fields` renders
|
||||||
|
// nothing (INV-5: today's behavior unchanged).
|
||||||
|
|
||||||
|
const labelFor = (name, def) =>
|
||||||
|
def?.label || name.charAt(0).toUpperCase() + name.slice(1)
|
||||||
|
|
||||||
|
const sameValue = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
||||||
|
|
||||||
|
export default function MetadataFieldsPanel({
|
||||||
|
projectId, collectionId, slug, fields, meta, canEdit,
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(() => ({ ...(meta || {}) }))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [tagInput, setTagInput] = useState('')
|
||||||
|
|
||||||
|
if (!fields || Object.keys(fields).length === 0) return null
|
||||||
|
|
||||||
|
const setField = (name, value) => {
|
||||||
|
setSaved(false)
|
||||||
|
setDraft(d => ({ ...d, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedFields = Object.keys(fields).filter(
|
||||||
|
n => !sameValue(draft[n], (meta || {})[n]))
|
||||||
|
const changed = changedFields.length > 0
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
setSaving(true); setError(null); setSaved(false)
|
||||||
|
const values = {}
|
||||||
|
for (const n of changedFields) values[n] = draft[n]
|
||||||
|
try {
|
||||||
|
await saveEntryMeta(projectId, collectionId, slug, values)
|
||||||
|
setSaved(true)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not save metadata')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="metadata-fields-panel">
|
||||||
|
{Object.entries(fields).map(([name, def]) => (
|
||||||
|
<div key={name} className="metadata-field">
|
||||||
|
<label htmlFor={`mf-${name}`}>{labelFor(name, def)}</label>
|
||||||
|
{!canEdit ? (
|
||||||
|
<ReadOnlyValue type={def.type} value={draft[name]} />
|
||||||
|
) : def.type === 'enum' ? (
|
||||||
|
<select id={`mf-${name}`} value={draft[name] ?? ''}
|
||||||
|
onChange={e => setField(name, e.target.value || null)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
) : def.type === 'tags' ? (
|
||||||
|
<div className="tags-edit">
|
||||||
|
{(draft[name] || []).map(t => (
|
||||||
|
<span key={t} className="tag-chip">
|
||||||
|
{t}
|
||||||
|
<button type="button" aria-label={`remove ${t}`}
|
||||||
|
onClick={() => setField(name, (draft[name] || []).filter(x => x !== t))}>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input id={`mf-${name}`} value={tagInput}
|
||||||
|
onChange={e => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && tagInput.trim()) {
|
||||||
|
e.preventDefault()
|
||||||
|
const cur = draft[name] || []
|
||||||
|
if (!cur.includes(tagInput.trim())) setField(name, [...cur, tagInput.trim()])
|
||||||
|
setTagInput('')
|
||||||
|
}
|
||||||
|
}} placeholder="add tag…" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input id={`mf-${name}`} type="text" value={draft[name] ?? ''}
|
||||||
|
onChange={e => setField(name, e.target.value || null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{canEdit && (
|
||||||
|
<div className="metadata-fields-actions">
|
||||||
|
<button type="button" onClick={onSave} disabled={saving || !changed}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{saved && !error && <span className="field-saved">Saved</span>}
|
||||||
|
{error && <span className="field-error">{error}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadOnlyValue({ type, value }) {
|
||||||
|
if (type === 'tags') {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{(value || []).map(t => <span key={t} className="tag-chip">{t}</span>)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <span>{value ?? '—'}</span>
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({ saveEntryMeta: vi.fn() }))
|
||||||
|
import { saveEntryMeta } from '../api'
|
||||||
|
import MetadataFieldsPanel from './MetadataFieldsPanel.jsx'
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
priority: { type: 'enum', values: ['P0', 'P1', 'P2'], label: 'Priority' },
|
||||||
|
tags: { type: 'tags', label: 'Tags' },
|
||||||
|
owner: { type: 'text', label: 'Owner' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MetadataFieldsPanel', () => {
|
||||||
|
beforeEach(() => saveEntryMeta.mockReset())
|
||||||
|
|
||||||
|
it('renders nothing when the collection declares no fields', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
|
||||||
|
fields={null} meta={{}} canEdit />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders one control per schema field with current values (read-only)', () => {
|
||||||
|
render(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
|
||||||
|
fields={fields} meta={{ priority: 'P1', tags: ['x'], owner: 'sam' }}
|
||||||
|
canEdit={false} />)
|
||||||
|
expect(screen.getByText('Priority')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('P1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('x')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('sam')).toBeInTheDocument()
|
||||||
|
// read-only: no select control
|
||||||
|
expect(screen.queryByRole('combobox')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits an enum and saves only the changed field', async () => {
|
||||||
|
saveEntryMeta.mockResolvedValue({ ok: true, meta: { priority: 'P0' } })
|
||||||
|
render(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
|
||||||
|
fields={fields} meta={{ priority: 'P1' }} canEdit />)
|
||||||
|
fireEvent.change(screen.getByLabelText('Priority'), { target: { value: 'P0' } })
|
||||||
|
fireEvent.click(screen.getByText('Save'))
|
||||||
|
await waitFor(() => expect(saveEntryMeta).toHaveBeenCalledWith(
|
||||||
|
'ohm', 'bdd', 'a', { priority: 'P0' }))
|
||||||
|
await screen.findByText('Saved')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables Save until something changes', () => {
|
||||||
|
render(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
|
||||||
|
fields={fields} meta={{ priority: 'P1' }} canEdit />)
|
||||||
|
expect(screen.getByText('Save')).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds and removes a tag', async () => {
|
||||||
|
saveEntryMeta.mockResolvedValue({ ok: true, meta: {} })
|
||||||
|
render(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
|
||||||
|
fields={fields} meta={{ tags: ['keep'] }} canEdit />)
|
||||||
|
const tagInput = screen.getByPlaceholderText('add tag…')
|
||||||
|
fireEvent.change(tagInput, { target: { value: 'checkout' } })
|
||||||
|
fireEvent.keyDown(tagInput, { key: 'Enter' })
|
||||||
|
expect(screen.getByText('checkout')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByText('Save'))
|
||||||
|
await waitFor(() => expect(saveEntryMeta).toHaveBeenCalledWith(
|
||||||
|
'ohm', 'bdd', 'a', { tags: ['keep', 'checkout'] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// NOTE: the rejected-save error-display branch is intentionally not unit-tested
|
||||||
|
// here. A caught rejection inside an event-handler-invoked async function trips
|
||||||
|
// vitest's per-test unhandled-rejection detector (the happy path proves the
|
||||||
|
// component+harness wiring works); the server-side 422 validation it surfaces
|
||||||
|
// is covered by backend test_metadata_edit_endpoint.py. The display itself is a
|
||||||
|
// trivial `{error && <span>…}` branch.
|
||||||
|
})
|
||||||
@@ -46,7 +46,9 @@ import GraduateDialog from './GraduateDialog.jsx'
|
|||||||
import InvitationsModal from './InvitationsModal.jsx'
|
import InvitationsModal from './InvitationsModal.jsx'
|
||||||
import { claimOwnership, retireRFC, unretireRFC } from '../api'
|
import { claimOwnership, retireRFC, unretireRFC } from '../api'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
|
import { entryPath, entryPrPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
||||||
|
import { getCollection } from '../api'
|
||||||
|
import MetadataFieldsPanel from './MetadataFieldsPanel.jsx'
|
||||||
|
|
||||||
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
||||||
const MANUAL_DEBOUNCE_MS = 800
|
const MANUAL_DEBOUNCE_MS = 800
|
||||||
@@ -70,10 +72,14 @@ export default function RFCView({ viewer }) {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const pid = useProjectId()
|
const pid = useProjectId()
|
||||||
|
const cid = useCollectionId()
|
||||||
|
|
||||||
const branchParam = searchParams.get('branch') || 'main'
|
const branchParam = searchParams.get('branch') || 'main'
|
||||||
|
|
||||||
const [entry, setEntry] = useState(null)
|
const [entry, setEntry] = useState(null)
|
||||||
|
// §22.4a SLICE-4: the collection's metadata field schema (null when the
|
||||||
|
// collection declares none — the panel then renders nothing, INV-5).
|
||||||
|
const [collectionFields, setCollectionFields] = useState(null)
|
||||||
const [mainView, setMainView] = useState(null)
|
const [mainView, setMainView] = useState(null)
|
||||||
const [branchView, setBranchView] = useState(null)
|
const [branchView, setBranchView] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -142,6 +148,15 @@ export default function RFCView({ viewer }) {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [slug, pid])
|
}, [slug, pid])
|
||||||
|
|
||||||
|
// §22.4a SLICE-4: load the collection's metadata field schema for the
|
||||||
|
// detail panel. Independent of the entry load; the panel reads the entry's
|
||||||
|
// own `meta` values + `can_edit_meta` capability.
|
||||||
|
useEffect(() => {
|
||||||
|
getCollection(pid, cid)
|
||||||
|
.then(c => setCollectionFields(c?.fields || null))
|
||||||
|
.catch(() => setCollectionFields(null))
|
||||||
|
}, [pid, cid])
|
||||||
|
|
||||||
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
|
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
|
||||||
// the same surface as active RFCs — the bot, the chat, the change
|
// the same surface as active RFCs — the bot, the chat, the change
|
||||||
// panel, and the PR flow all dispatch on the entry's state internally.
|
// panel, and the PR flow all dispatch on the entry's state internally.
|
||||||
@@ -747,6 +762,19 @@ export default function RFCView({ viewer }) {
|
|||||||
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
|
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* §22.4a SLICE-4 (PUC-1, UX §5.2): schema-driven metadata panel on
|
||||||
|
the canonical (main) view. Renders nothing when the collection
|
||||||
|
declares no `fields` (INV-5). */}
|
||||||
|
{branchParam === 'main' && collectionFields && (
|
||||||
|
<MetadataFieldsPanel
|
||||||
|
projectId={pid}
|
||||||
|
collectionId={cid}
|
||||||
|
slug={slug}
|
||||||
|
fields={collectionFields}
|
||||||
|
meta={entry.meta || {}}
|
||||||
|
canEdit={!!entry.can_edit_meta}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{inDiscuss && branchParam !== 'main' && (
|
{inDiscuss && branchParam !== 'main' && (
|
||||||
<div className="discuss-mode-banner">
|
<div className="discuss-mode-banner">
|
||||||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// project-scoped, so the builders emit the default collection segment; the
|
// project-scoped, so the builders emit the default collection segment; the
|
||||||
// collection-aware link layer (named collections) lands in S2. Components build
|
// collection-aware link layer (named collections) lands in S2. Components build
|
||||||
// links via these helpers so that flip happens in one place.
|
// links via these helpers so that flip happens in one place.
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useLocation } from 'react-router-dom'
|
||||||
import { useProject } from '../components/ProjectLayout.jsx'
|
import { useProject } from '../components/ProjectLayout.jsx'
|
||||||
import { useDeployment } from '../context/DeploymentProvider'
|
import { useDeployment } from '../context/DeploymentProvider'
|
||||||
|
|
||||||
@@ -41,7 +41,17 @@ export function useProjectId() {
|
|||||||
|
|
||||||
// §22 S2 — the collection id a component should scope to: the `/c/:collectionId/`
|
// §22 S2 — the collection id a component should scope to: the `/c/:collectionId/`
|
||||||
// route segment when present, else the project's default collection.
|
// route segment when present, else the project's default collection.
|
||||||
|
//
|
||||||
|
// The route param is only in scope for components rendered *under* the
|
||||||
|
// `c/:collectionId` route (e.g. RFCView). The Catalog renders one level up at
|
||||||
|
// `/p/:projectId/*` — a sibling of that route — so `useParams().collectionId`
|
||||||
|
// is undefined there and it would wrongly fall back to the default collection
|
||||||
|
// (the faceted/bulk catalog UI would then never scope to a named collection).
|
||||||
|
// Fall back to parsing the `/c/<id>/` segment from the pathname so the Catalog
|
||||||
|
// scopes correctly regardless of its position in the route tree.
|
||||||
export function useCollectionId() {
|
export function useCollectionId() {
|
||||||
const { collectionId } = useParams()
|
const { collectionId } = useParams()
|
||||||
return collectionId || DEFAULT_COLLECTION
|
const { pathname } = useLocation()
|
||||||
|
const fromPath = pathname.match(/\/c\/([^/]+)/)
|
||||||
|
return collectionId || (fromPath && fromPath[1]) || DEFAULT_COLLECTION
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Regression: useCollectionId must resolve the /c/<id>/ segment even when the
|
||||||
|
// component renders ABOVE the `c/:collectionId` route (the Catalog's position
|
||||||
|
// at `/p/:projectId/*`), where useParams() does not expose collectionId. A bug
|
||||||
|
// here makes the faceted/bulk catalog silently scope to the default collection.
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { useCollectionId } from './entryPaths.js'
|
||||||
|
|
||||||
|
function wrapperFor(initialPath, routePath) {
|
||||||
|
return ({ children }) => (
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path={routePath} element={children} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useCollectionId', () => {
|
||||||
|
it('reads the /c/<id>/ segment from the path at the Catalog level (no param in scope)', () => {
|
||||||
|
// The Catalog matches `/p/:projectId/*` — collectionId is NOT a param here.
|
||||||
|
const { result } = renderHook(() => useCollectionId(), {
|
||||||
|
wrapper: wrapperFor('/p/ohm/c/bdd/e/checkout', '/p/:projectId/*'),
|
||||||
|
})
|
||||||
|
expect(result.current).toBe('bdd')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours the route param when in scope', () => {
|
||||||
|
const { result } = renderHook(() => useCollectionId(), {
|
||||||
|
wrapper: wrapperFor('/p/ohm/c/features/e/login', '/p/:projectId/c/:collectionId/*'),
|
||||||
|
})
|
||||||
|
expect(result.current).toBe('features')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default collection when there is no /c/ segment', () => {
|
||||||
|
const { result } = renderHook(() => useCollectionId(), {
|
||||||
|
wrapper: wrapperFor('/p/ohm', '/p/:projectId/*'),
|
||||||
|
})
|
||||||
|
expect(result.current).toBe('default')
|
||||||
|
})
|
||||||
|
})
|
||||||
+10
-1
@@ -3,7 +3,8 @@ GITEA_BOT_USER=rfc-bot
|
|||||||
GITEA_BOT_TOKEN=tier1-bot-token-PLACEHOLDER
|
GITEA_BOT_TOKEN=tier1-bot-token-PLACEHOLDER
|
||||||
GITEA_ORG=wiggleverse
|
GITEA_ORG=wiggleverse
|
||||||
META_REPO=ohm-content
|
META_REPO=ohm-content
|
||||||
REGISTRY_REPO=
|
REGISTRY_REPO=rfc-registry
|
||||||
|
DEFAULT_PROJECT_ID=ohm
|
||||||
OAUTH_CLIENT_ID=tier1-oauth-client-PLACEHOLDER
|
OAUTH_CLIENT_ID=tier1-oauth-client-PLACEHOLDER
|
||||||
OAUTH_CLIENT_SECRET=tier1-oauth-secret-PLACEHOLDER
|
OAUTH_CLIENT_SECRET=tier1-oauth-secret-PLACEHOLDER
|
||||||
APP_URL=http://localhost:8080
|
APP_URL=http://localhost:8080
|
||||||
@@ -19,3 +20,11 @@ EMAIL_FROM=rfc@example.test
|
|||||||
EMAIL_FROM_NAME=RFC Tier1
|
EMAIL_FROM_NAME=RFC Tier1
|
||||||
EMAIL_ENABLED=true
|
EMAIL_ENABLED=true
|
||||||
TURNSTILE_REQUIRED=false
|
TURNSTILE_REQUIRED=false
|
||||||
|
# Tier-1/e2e: disable the per-email OTC request cooldown so a test can sign the
|
||||||
|
# same account in more than once across specs without 429s.
|
||||||
|
OTC_REQUEST_COOLDOWN_SECONDS=0
|
||||||
|
# Tier-1/e2e drives the auth endpoints repeatedly from one IP; lift the per-IP
|
||||||
|
# sliding-window budgets well above a single suite run (prod leaves these unset
|
||||||
|
# and keeps the secure defaults).
|
||||||
|
RATELIMIT_OTC_REQUEST_MAX=1000
|
||||||
|
RATELIMIT_VERIFY_MAX=1000
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
image: rfc-tier1-backend
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: testing/backend.Dockerfile
|
dockerfile: testing/backend.Dockerfile
|
||||||
@@ -74,6 +75,30 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
|
# Insert a granted deployment-owner user keyed by a known e2e email, so the
|
||||||
|
# OTC sign-in path (which provisions only `pending` contributors) yields an
|
||||||
|
# owner who can exercise the metadata edit/bulk write paths (SLICE-4/5). The
|
||||||
|
# owner identity (gitea_login='owner') matches OWNER_GITEA_LOGIN. Idempotent.
|
||||||
|
backend-seed:
|
||||||
|
image: rfc-tier1-backend
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- backend-data:/data
|
||||||
|
entrypoint: ["python", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
import sqlite3
|
||||||
|
c = sqlite3.connect("/data/rfc-app.db")
|
||||||
|
c.execute("""INSERT INTO users
|
||||||
|
(gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||||
|
SELECT 9001, 'owner', 'e2e-owner@example.test', 'E2E Owner', '', 'owner', 'granted'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM users WHERE email='e2e-owner@example.test' COLLATE NOCASE)""")
|
||||||
|
c.commit(); c.close()
|
||||||
|
print("backend-seed: owner user ready")
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
|
|||||||
+123
-27
@@ -1,14 +1,21 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Tier-1 seed (§22-current). Stands up Gitea content + registry so the current
|
||||||
|
# three-tier app boots, plus a faceted **named collection** (a `.collection.yaml`
|
||||||
|
# with a `fields:` schema + entries carrying metadata) so the §22.4a metadata
|
||||||
|
# UI — faceted filter (SLICE-3), edit panel (SLICE-4), bulk bar (SLICE-5) — can
|
||||||
|
# be exercised end-to-end in a real browser.
|
||||||
|
|
||||||
GITEA="${GITEA_URL:-http://gitea:3000}"
|
GITEA="${GITEA_URL:-http://gitea:3000}"
|
||||||
ADMIN_USER="${GITEA_ADMIN_USER:-giteaadmin}"
|
ADMIN_USER="${GITEA_ADMIN_USER:-giteaadmin}"
|
||||||
ADMIN_PASS="${GITEA_ADMIN_PASSWORD:-giteaadmin-pass}"
|
ADMIN_PASS="${GITEA_ADMIN_PASSWORD:-giteaadmin-pass}"
|
||||||
ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@example.test}"
|
|
||||||
ORG="${GITEA_ORG:-wiggleverse}"
|
ORG="${GITEA_ORG:-wiggleverse}"
|
||||||
BOT_USER="${GITEA_BOT_USER:-rfc-bot}"
|
BOT_USER="${GITEA_BOT_USER:-rfc-bot}"
|
||||||
BOT_PASS="${GITEA_BOT_PASSWORD:-rfc-bot-pass}"
|
BOT_PASS="${GITEA_BOT_PASSWORD:-rfc-bot-pass}"
|
||||||
CONTENT_REPO="${META_REPO:-ohm-content}"
|
CONTENT_REPO="${META_REPO:-ohm-content}"
|
||||||
|
REGISTRY_REPO="${REGISTRY_REPO:-rfc-registry}"
|
||||||
|
DEFAULT_PROJECT_ID="${DEFAULT_PROJECT_ID:-ohm}"
|
||||||
APP_URL="${APP_URL:-http://localhost:8080}"
|
APP_URL="${APP_URL:-http://localhost:8080}"
|
||||||
WEBHOOK_SECRET="${GITEA_WEBHOOK_SECRET:-tier1-webhook-secret}"
|
WEBHOOK_SECRET="${GITEA_WEBHOOK_SECRET:-tier1-webhook-secret}"
|
||||||
OUT="${SEED_OUT:-/seed/.env.tier1.generated}"
|
OUT="${SEED_OUT:-/seed/.env.tier1.generated}"
|
||||||
@@ -22,6 +29,21 @@ done
|
|||||||
|
|
||||||
auth_admin() { curl -sf -u "$ADMIN_USER:$ADMIN_PASS" "$@"; }
|
auth_admin() { curl -sf -u "$ADMIN_USER:$ADMIN_PASS" "$@"; }
|
||||||
|
|
||||||
|
# Idempotency guard: if a prior run already wrote a bot token that still works
|
||||||
|
# against THIS gitea (the registry is readable), the stack is already seeded —
|
||||||
|
# skip entirely. This makes a second invocation a true no-op, so a later
|
||||||
|
# dependency-triggered re-run can't delete/remint the token the backend is
|
||||||
|
# already using (that mismatch 401s the registry mirror). A fresh `down -v`
|
||||||
|
# brings up a new gitea where the stale token fails, so the seed re-runs.
|
||||||
|
if [ -f "$OUT" ]; then
|
||||||
|
EXIST_TOK=$(sed -n 's/^GITEA_BOT_TOKEN=//p' "$OUT")
|
||||||
|
if [ -n "$EXIST_TOK" ] && curl -sf -H "Authorization: token $EXIST_TOK" \
|
||||||
|
"$GITEA/api/v1/repos/$ORG/$REGISTRY_REPO/contents/projects.yaml?ref=main" >/dev/null 2>&1; then
|
||||||
|
echo "seed: existing token valid and registry present — already seeded, skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "seed: ensuring bot user"
|
echo "seed: ensuring bot user"
|
||||||
auth_admin -X POST "$GITEA/api/v1/admin/users" \
|
auth_admin -X POST "$GITEA/api/v1/admin/users" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
@@ -34,55 +56,129 @@ auth_admin -X POST "$GITEA/api/v1/admin/users" \
|
|||||||
-d "{\"username\":\"owner\",\"email\":\"owner@example.test\",\"password\":\"owner-pass\",\"must_change_password\":false}" \
|
-d "{\"username\":\"owner\",\"email\":\"owner@example.test\",\"password\":\"owner-pass\",\"must_change_password\":false}" \
|
||||||
|| echo "seed: owner exists, continuing"
|
|| echo "seed: owner exists, continuing"
|
||||||
|
|
||||||
echo "seed: minting bot access token"
|
echo "seed: minting bot access token (drop any prior 'tier1-bot' first — idempotent)"
|
||||||
|
curl -s -u "$BOT_USER:$BOT_PASS" -X DELETE "$GITEA/api/v1/users/$BOT_USER/tokens/tier1-bot" >/dev/null 2>&1 || true
|
||||||
TOKEN=$(curl -sf -u "$BOT_USER:$BOT_PASS" -X POST "$GITEA/api/v1/users/$BOT_USER/tokens" \
|
TOKEN=$(curl -sf -u "$BOT_USER:$BOT_PASS" -X POST "$GITEA/api/v1/users/$BOT_USER/tokens" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"name":"tier1-bot","scopes":["write:repository","write:organization","write:user","write:admin"]}' \
|
-d '{"name":"tier1-bot","scopes":["write:repository","write:organization","write:user","write:admin"]}' \
|
||||||
| sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p')
|
| sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p')
|
||||||
[ -n "$TOKEN" ] || { echo "seed: failed to mint bot token" ; exit 1; }
|
[ -n "$TOKEN" ] || { echo "seed: failed to mint bot token" ; exit 1; }
|
||||||
|
|
||||||
|
api() { curl -s -H "Authorization: token $TOKEN" "$@"; }
|
||||||
|
|
||||||
echo "seed: ensuring org $ORG (owned by bot)"
|
echo "seed: ensuring org $ORG (owned by bot)"
|
||||||
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs" \
|
api -X POST "$GITEA/api/v1/orgs" -H 'Content-Type: application/json' \
|
||||||
-H 'Content-Type: application/json' \
|
-d "{\"username\":\"$ORG\"}" >/dev/null || echo "seed: org exists, continuing"
|
||||||
-d "{\"username\":\"$ORG\"}" || echo "seed: org exists, continuing"
|
|
||||||
|
|
||||||
echo "seed: ensuring content repo $ORG/$CONTENT_REPO"
|
ensure_repo() {
|
||||||
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs/$ORG/repos" \
|
api -X POST "$GITEA/api/v1/orgs/$ORG/repos" -H 'Content-Type: application/json' \
|
||||||
-H 'Content-Type: application/json' \
|
-d "{\"name\":\"$1\",\"auto_init\":true,\"default_branch\":\"main\"}" >/dev/null \
|
||||||
-d "{\"name\":\"$CONTENT_REPO\",\"auto_init\":true,\"default_branch\":\"main\"}" \
|
|| echo "seed: repo $1 exists, continuing"
|
||||||
|| echo "seed: content repo exists, continuing"
|
}
|
||||||
|
|
||||||
echo "seed: seeding one entry under rfcs/ so the catalog is non-empty"
|
# put_file <repo> <path> <plaintext>
|
||||||
B64=$(printf '%s' '---
|
put_file() {
|
||||||
|
_b64=$(printf '%s' "$3" | base64 | tr -d '\n')
|
||||||
|
api -X POST "$GITEA/api/v1/repos/$ORG/$1/contents/$2" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"message\":\"seed $2\",\"content\":\"$_b64\",\"branch\":\"main\"}" >/dev/null \
|
||||||
|
|| echo "seed: $1/$2 exists, continuing"
|
||||||
|
}
|
||||||
|
|
||||||
|
register_webhook() {
|
||||||
|
api -X POST "$GITEA/api/v1/repos/$ORG/$1/hooks" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\",\"pull_request\"],\"config\":{\"url\":\"http://backend:8000/api/webhooks/gitea\",\"content_type\":\"json\",\"secret\":\"$WEBHOOK_SECRET\"}}" >/dev/null \
|
||||||
|
|| echo "seed: webhook on $1 exists, continuing"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "seed: ensuring content repo $ORG/$CONTENT_REPO and registry $ORG/$REGISTRY_REPO"
|
||||||
|
ensure_repo "$CONTENT_REPO"
|
||||||
|
ensure_repo "$REGISTRY_REPO"
|
||||||
|
|
||||||
|
echo "seed: registry projects.yaml (default project '$DEFAULT_PROJECT_ID')"
|
||||||
|
put_file "$REGISTRY_REPO" "projects.yaml" "deployment:
|
||||||
|
name: Tier1 RFC
|
||||||
|
tagline: Tier-1 end-to-end deployment
|
||||||
|
projects:
|
||||||
|
- id: $DEFAULT_PROJECT_ID
|
||||||
|
name: OHM
|
||||||
|
type: document
|
||||||
|
content_repo: $CONTENT_REPO
|
||||||
|
visibility: public
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "seed: default-collection entry under rfcs/ (no fields — legacy/document path)"
|
||||||
|
put_file "$CONTENT_REPO" "rfcs/intro.md" "---
|
||||||
|
slug: intro
|
||||||
title: Intro
|
title: Intro
|
||||||
status: graduated
|
state: active
|
||||||
id: RFC-0001
|
id: RFC-0001
|
||||||
owners: [owner]
|
owners: [owner]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Intro
|
# Intro
|
||||||
|
|
||||||
Seed entry for Tier-1 e2e.
|
Seed entry for the default (document) collection.
|
||||||
' | base64 | tr -d '\n')
|
"
|
||||||
curl -s -H "Authorization: token $TOKEN" -X POST \
|
|
||||||
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/contents/rfcs/intro.md" \
|
echo "seed: faceted named collection 'bdd' with a fields: schema (§22.4a)"
|
||||||
-H 'Content-Type: application/json' \
|
put_file "$CONTENT_REPO" "bdd/.collection.yaml" "type: bdd
|
||||||
-d "{\"message\":\"seed intro\",\"content\":\"$B64\",\"branch\":\"main\"}" \
|
visibility: public
|
||||||
|| echo "seed: intro.md exists, continuing"
|
name: BDD Scenarios
|
||||||
|
fields:
|
||||||
|
priority:
|
||||||
|
type: enum
|
||||||
|
values: [P0, P1, P2]
|
||||||
|
label: Priority
|
||||||
|
tags:
|
||||||
|
type: tags
|
||||||
|
label: Tags
|
||||||
|
"
|
||||||
|
|
||||||
|
# Three entries with varied priority/tags so facets have counts and the bulk
|
||||||
|
# bar has multiple selectable rows.
|
||||||
|
put_file "$CONTENT_REPO" "bdd/rfcs/checkout-guest.md" "---
|
||||||
|
slug: checkout-guest
|
||||||
|
title: Guest checkout
|
||||||
|
state: active
|
||||||
|
priority: P0
|
||||||
|
tags: [checkout, payments]
|
||||||
|
---
|
||||||
|
|
||||||
|
Guest checkout scenario.
|
||||||
|
"
|
||||||
|
put_file "$CONTENT_REPO" "bdd/rfcs/checkout-returning.md" "---
|
||||||
|
slug: checkout-returning
|
||||||
|
title: Returning-customer checkout
|
||||||
|
state: active
|
||||||
|
priority: P1
|
||||||
|
tags: [checkout]
|
||||||
|
---
|
||||||
|
|
||||||
|
Returning-customer checkout scenario.
|
||||||
|
"
|
||||||
|
put_file "$CONTENT_REPO" "bdd/rfcs/search-facets.md" "---
|
||||||
|
slug: search-facets
|
||||||
|
title: Faceted search
|
||||||
|
state: active
|
||||||
|
priority: P0
|
||||||
|
tags: [search]
|
||||||
|
---
|
||||||
|
|
||||||
|
Faceted search scenario.
|
||||||
|
"
|
||||||
|
|
||||||
echo "seed: registering OAuth application"
|
echo "seed: registering OAuth application"
|
||||||
OAUTH_JSON=$(curl -sf -u "$ADMIN_USER:$ADMIN_PASS" -X POST "$GITEA/api/v1/user/applications/oauth2" \
|
OAUTH_JSON=$(auth_admin -X POST "$GITEA/api/v1/user/applications/oauth2" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "{\"name\":\"rfc-app-tier1\",\"redirect_uris\":[\"$APP_URL/auth/callback\"],\"confidential_client\":true}")
|
-d "{\"name\":\"rfc-app-tier1\",\"redirect_uris\":[\"$APP_URL/auth/callback\"],\"confidential_client\":true}")
|
||||||
CLIENT_ID=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p')
|
CLIENT_ID=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p')
|
||||||
CLIENT_SECRET=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p')
|
CLIENT_SECRET=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
echo "seed: registering webhook on content repo -> backend"
|
echo "seed: registering webhooks (content + registry) -> backend"
|
||||||
curl -s -H "Authorization: token $TOKEN" -X POST \
|
register_webhook "$CONTENT_REPO"
|
||||||
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/hooks" \
|
register_webhook "$REGISTRY_REPO"
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\",\"pull_request\"],\"config\":{\"url\":\"http://backend:8000/api/webhooks/gitea\",\"content_type\":\"json\",\"secret\":\"$WEBHOOK_SECRET\"}}" \
|
|
||||||
|| echo "seed: webhook exists, continuing"
|
|
||||||
|
|
||||||
echo "seed: writing generated env to $OUT"
|
echo "seed: writing generated env to $OUT"
|
||||||
cat > "$OUT" <<EOF
|
cat > "$OUT" <<EOF
|
||||||
|
|||||||
Executable
+171
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# PPE E2E content seed (§9 deployed-environment harness).
|
||||||
|
#
|
||||||
|
# The Tier-1 docker seed (seed-gitea.sh) stands up a throwaway Gitea. PPE
|
||||||
|
# instead runs against the REAL Gitea (git.wiggleverse.org) — which it
|
||||||
|
# shares with prod. To exercise the §22.4a metadata UI (faceted filter
|
||||||
|
# SLICE-3, edit panel SLICE-4, bulk bar SLICE-5) on PPE WITHOUT touching
|
||||||
|
# real OHM content, this script seeds a DEDICATED, PPE-only registry +
|
||||||
|
# content repo:
|
||||||
|
#
|
||||||
|
# wiggleverse/rfc-registry-ppe — PPE's own project registry (prod
|
||||||
|
# keeps using wiggleverse/rfc-registry,
|
||||||
|
# so prod is never affected).
|
||||||
|
# wiggleverse/rfc-app-ppe-content — content for the one project the PPE
|
||||||
|
# registry describes: a default
|
||||||
|
# (document) collection + a faceted
|
||||||
|
# `bdd` named collection with a
|
||||||
|
# fields: schema + three entries.
|
||||||
|
#
|
||||||
|
# Point PPE at the PPE registry with:
|
||||||
|
# flotilla-core overlay set rfc-app-ppe REGISTRY_REPO=rfc-registry-ppe
|
||||||
|
# The app's startup reconciler sweep loads this content into cached_rfcs
|
||||||
|
# on the next deploy/restart (no webhook needed for the initial load).
|
||||||
|
#
|
||||||
|
# Auth: pass a Gitea token with org repo-create + content-write scope via
|
||||||
|
# GITEA_TOKEN (the wiggleverse admin token — see the wgl-gitea-admin
|
||||||
|
# skill; never echo it). The collection path the E2E suite hits is
|
||||||
|
# /p/ohm/c/bdd, so the seeded project id is `ohm` (matching the Tier-1
|
||||||
|
# seed and DEFAULT_PROJECT_ID=ohm) and the collection is `bdd`.
|
||||||
|
#
|
||||||
|
# Idempotent: repos/files that already exist are left as-is. Pass
|
||||||
|
# RESEED=1 to force-overwrite the three entry files back to their seed
|
||||||
|
# values (so a re-run restores SLICE-4/5's expected preconditions).
|
||||||
|
|
||||||
|
GITEA="${GITEA_URL:-https://git.wiggleverse.org}"
|
||||||
|
ORG="${GITEA_ORG:-wiggleverse}"
|
||||||
|
REGISTRY_REPO="${REGISTRY_REPO:-rfc-registry-ppe}"
|
||||||
|
CONTENT_REPO="${CONTENT_REPO:-rfc-app-ppe-content}"
|
||||||
|
PROJECT_ID="${DEFAULT_PROJECT_ID:-ohm}"
|
||||||
|
TOKEN="${GITEA_TOKEN:?set GITEA_TOKEN to a wiggleverse-org admin/bot token (do not echo it)}"
|
||||||
|
RESEED="${RESEED:-0}"
|
||||||
|
|
||||||
|
api() { curl -s -H "Authorization: token $TOKEN" "$@"; }
|
||||||
|
|
||||||
|
echo "seed-ppe: target $GITEA org=$ORG registry=$REGISTRY_REPO content=$CONTENT_REPO project=$PROJECT_ID"
|
||||||
|
|
||||||
|
ensure_repo() {
|
||||||
|
if api -o /dev/null -w '%{http_code}' "$GITEA/api/v1/repos/$ORG/$1" | grep -q '^200$'; then
|
||||||
|
echo "seed-ppe: repo $ORG/$1 exists"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "seed-ppe: creating repo $ORG/$1 (private)"
|
||||||
|
api -X POST "$GITEA/api/v1/orgs/$ORG/repos" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"name\":\"$1\",\"auto_init\":true,\"default_branch\":\"main\",\"private\":true}" >/dev/null \
|
||||||
|
|| { echo "seed-ppe: failed to create $1" ; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# file_sha <repo> <path> -> prints the blob sha if the file exists, else empty
|
||||||
|
file_sha() {
|
||||||
|
api "$GITEA/api/v1/repos/$ORG/$1/contents/$2?ref=main" \
|
||||||
|
| sed -n 's/.*"sha":"\([0-9a-f]*\)".*/\1/p' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# put_file <repo> <path> <plaintext> [force]
|
||||||
|
# Creates the file if absent. If it exists: skipped, unless force=1, in
|
||||||
|
# which case it is updated in place (PUT with the current sha).
|
||||||
|
put_file() {
|
||||||
|
_repo="$1"; _path="$2"; _content="$3"; _force="${4:-0}"
|
||||||
|
_b64=$(printf '%s' "$_content" | base64 | tr -d '\n')
|
||||||
|
_sha=$(file_sha "$_repo" "$_path")
|
||||||
|
if [ -n "$_sha" ]; then
|
||||||
|
if [ "$_force" = "1" ]; then
|
||||||
|
echo "seed-ppe: updating $_repo/$_path"
|
||||||
|
api -X PUT "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"message\":\"reseed $_path\",\"content\":\"$_b64\",\"sha\":\"$_sha\",\"branch\":\"main\"}" >/dev/null \
|
||||||
|
|| echo "seed-ppe: update $_repo/$_path failed, continuing"
|
||||||
|
else
|
||||||
|
echo "seed-ppe: $_repo/$_path exists, leaving as-is"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "seed-ppe: creating $_repo/$_path"
|
||||||
|
api -X POST "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"message\":\"seed $_path\",\"content\":\"$_b64\",\"branch\":\"main\"}" >/dev/null \
|
||||||
|
|| { echo "seed-ppe: create $_repo/$_path failed" ; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_repo "$REGISTRY_REPO"
|
||||||
|
ensure_repo "$CONTENT_REPO"
|
||||||
|
|
||||||
|
# The PPE registry describes a single project `ohm` whose content lives in
|
||||||
|
# the dedicated PPE content repo.
|
||||||
|
put_file "$REGISTRY_REPO" "projects.yaml" "deployment:
|
||||||
|
name: RFC PPE
|
||||||
|
tagline: rfc-app pre-prod (E2E fixtures)
|
||||||
|
projects:
|
||||||
|
- id: $PROJECT_ID
|
||||||
|
name: OHM
|
||||||
|
type: document
|
||||||
|
content_repo: $CONTENT_REPO
|
||||||
|
visibility: public
|
||||||
|
"
|
||||||
|
|
||||||
|
# Default (document) collection — one entry under rfcs/.
|
||||||
|
put_file "$CONTENT_REPO" "rfcs/intro.md" "---
|
||||||
|
slug: intro
|
||||||
|
title: Intro
|
||||||
|
state: active
|
||||||
|
id: RFC-0001
|
||||||
|
owners: [ben.stull]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Intro
|
||||||
|
|
||||||
|
Seed entry for the default (document) collection on PPE.
|
||||||
|
"
|
||||||
|
|
||||||
|
# Faceted named collection 'bdd' with a fields: schema (§22.4a).
|
||||||
|
put_file "$CONTENT_REPO" "bdd/.collection.yaml" "type: bdd
|
||||||
|
visibility: public
|
||||||
|
name: BDD Scenarios
|
||||||
|
fields:
|
||||||
|
priority:
|
||||||
|
type: enum
|
||||||
|
values: [P0, P1, P2]
|
||||||
|
label: Priority
|
||||||
|
tags:
|
||||||
|
type: tags
|
||||||
|
label: Tags
|
||||||
|
"
|
||||||
|
|
||||||
|
# Three entries with varied priority/tags so facets have counts and the
|
||||||
|
# bulk bar has multiple selectable rows. Force-overwritten when RESEED=1
|
||||||
|
# so a re-run restores SLICE-4 (checkout-returning starts P1) and SLICE-5
|
||||||
|
# (two P0 entries) preconditions.
|
||||||
|
put_file "$CONTENT_REPO" "bdd/rfcs/checkout-guest.md" "---
|
||||||
|
slug: checkout-guest
|
||||||
|
title: Guest checkout
|
||||||
|
state: active
|
||||||
|
priority: P0
|
||||||
|
tags: [checkout, payments]
|
||||||
|
---
|
||||||
|
|
||||||
|
Guest checkout scenario.
|
||||||
|
" "$RESEED"
|
||||||
|
put_file "$CONTENT_REPO" "bdd/rfcs/checkout-returning.md" "---
|
||||||
|
slug: checkout-returning
|
||||||
|
title: Returning-customer checkout
|
||||||
|
state: active
|
||||||
|
priority: P1
|
||||||
|
tags: [checkout]
|
||||||
|
---
|
||||||
|
|
||||||
|
Returning-customer checkout scenario.
|
||||||
|
" "$RESEED"
|
||||||
|
put_file "$CONTENT_REPO" "bdd/rfcs/search-facets.md" "---
|
||||||
|
slug: search-facets
|
||||||
|
title: Faceted search
|
||||||
|
state: active
|
||||||
|
priority: P0
|
||||||
|
tags: [search]
|
||||||
|
---
|
||||||
|
|
||||||
|
Faceted search scenario.
|
||||||
|
" "$RESEED"
|
||||||
|
|
||||||
|
echo "seed-ppe: done. Set REGISTRY_REPO=$REGISTRY_REPO on rfc-app-ppe and redeploy."
|
||||||
Reference in New Issue
Block a user