4e7410f90b
§22 migrated the READ/catalog path to the three-tier (project/collection)
model but the WRITE/branch/body subsystem still hardcoded the default
project's default collection — resolving every meta-resident entry to the
default content repo at rfcs/<slug>.md, ignoring the entry's project (its own
content repo) and collection (a <subfolder>/rfcs/ prefix). An entry outside
the default collection rendered a blank canonical body and every edit/PR/
body-write path hit the wrong file.
- New single resolver: projects.content_repo_for_collection() +
projects.entry_location(config, cid, slug) -> (org, repo, md_path)
(collection -> project -> content_repo + subfolder; falls back to the
default repo for a legacy/unknown collection).
- Every entry write/read path resolves via it: api_branches (body GET + all
branch write paths), api_prs (pr-draft/open/merge/withdraw/review/
resolution-branch), api_graduation (graduate/claim/retire/unretire +
orchestrator + state-flip), api.py mark-reviewed + idea-PR merge/decline/
withdraw + proposal preview, api_metadata. bot.open_metadata_pr and
bot.mark_entry_reviewed gained a file_path param. refresh_meta_branches,
the webhook corpus-refresh dispatch, and the hygiene branch-delete now
span every project's content repo, not just the default.
- New additive collection-scoped body-read routes:
GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main and
.../branches/{branch} disambiguate a slug across collections (G-5) and read
the entry's own repo. Slug-only routes kept (now collection-aware via the
cached row). Frontend getRFCMain/getBranch take optional pid+cid; RFCView
threads them (mirrors the v0.52.1 entry-detail fix).
No migration, no config change. Existing default-collection entries
unaffected. Tests: backend resolver + collection-scoped branch/body + graduate-
in-subfolder write path; frontend api unit. backend 677 / frontend 60 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
9.8 KiB
Python
216 lines
9.8 KiB
Python
"""§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(collection_id: str) -> tuple[str, str]:
|
|
# §22/G-15: the COLLECTION's project content_repo, not the deployment
|
|
# default — an entry in a non-default project writes its own repo.
|
|
# Falls back to the default repo for an unknown collection.
|
|
repo = (projects_mod.content_repo_for_collection(collection_id)
|
|
or (projects_mod.default_content_repo(config) or ""))
|
|
return config.gitea_org, repo
|
|
|
|
def _md_path(collection_id: str, slug: str) -> str:
|
|
sub = collections_mod.subfolder_of(collection_id) or ""
|
|
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(collection_id)
|
|
md_path = _md_path(collection_id, slug)
|
|
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
|
if st is None:
|
|
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(collection_id)
|
|
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(collection_id)
|
|
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
|