diff --git a/backend/app/api_metadata.py b/backend/app/api_metadata.py index 77ed8bb..7cc5759 100644 --- a/backend/app/api_metadata.py +++ b/backend/app/api_metadata.py @@ -26,6 +26,31 @@ 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() @@ -84,6 +109,76 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: "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) + 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} + @router.post("/api/projects/{project_id}/collections/{collection_id}/migrate") async def migrate( project_id: str, collection_id: str, request: Request diff --git a/backend/tests/test_metadata_bulk_endpoint.py b/backend/tests/test_metadata_bulk_endpoint.py new file mode 100644 index 0000000..7847eb6 --- /dev/null +++ b/backend/tests/test_metadata_bulk_endpoint.py @@ -0,0 +1,199 @@ +"""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_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 diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py index 70503b0..9f362be 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -54,6 +54,9 @@ class FakeGitea: self.repos: set[tuple[str, str]] = set() self._pr_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") # §22 M3: the deployment's project registry. Startup refresh_registry # reads projects.yaml here; the single 'default' project's content_repo @@ -288,6 +291,7 @@ class FakeGitea: 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"] diff --git a/docs/superpowers/plans/2026-06-07-slice5-bulk-metadata.md b/docs/superpowers/plans/2026-06-07-slice5-bulk-metadata.md new file mode 100644 index 0000000..0daae55 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-slice5-bulk-metadata.md @@ -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 ▾ · 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": }` +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:""}`. 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 : 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( {}} onClear={() => {}} />) + expect(screen.getByText(/3 selected/i)).toBeInTheDocument() + }) + + it('applies a set op when an enum value is chosen', () => { + const onApply = vi.fn() + render( {}} />) + 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( {}} />) + 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( {}} 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 " 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 ( +
+ {count} selected + {entries + .filter(([, d]) => d.type === 'enum') + .map(([name, def]) => ( + + ))} + {tagField && ( +
+ setTagValue(e.target.value)} + /> + + +
+ )} + +
+ ) +} +``` + +- [ ] **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 ``; 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 ( +
+ {selectable && ( + toggleSelect(r.slug)} + /> + )} + + {/* ...existing row internals unchanged... */} + +
+ ) +}) +``` + +> Preserve the existing row internals (`row-top`, `row-id`, malformed marker, `row-title`, `row-tags`) verbatim inside the ``. + +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 && ( + 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.