feat(slice5): POST .../meta/bulk one-commit bulk metadata edit (§22.4a PUC-2)
set/add/remove ops reusing the SLICE-4 sidecar write-through; per-entry partial-rejection; contributor+ gated (INV-4); validated at the write boundary. Tests cover one-commit, add/remove, partial reject, authz, and op/field/empty guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user