f05ee59763
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md (§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no sidecars present every corpus stays on the legacy frontmatter path, byte-identical (N=1 unchanged). - Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter, identical records (INV-6); unknown/forward-compat keys ride along through parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar (malformed/empty/slug-less) never drops the entry — slug backstopped from the filename stem, flagged not lost (INV-3). - Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles commit per collection (new `gitea.change_files`). Tested as a function; its Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become sidecar-aware first — see the design's SLICE-4 note + INV-8). No production trigger ships here, so no corpus is rewritten. - Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed` (additive); the corpus mirror derives it; catalog list + entry-detail APIs surface `metadata_malformed`. - INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild instead of dropping forward-compat keys. Gate: backend 575 passed (28 new: test_metadata / _migration / _cache + graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 + upgrade-steps; VERSION + frontend/package.json -> 0.47.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""Thin HTTP client for the Gitea REST API.
|
|
|
|
Read operations live here and are called from any module that needs them
|
|
(reconciler, super-draft body fetch, webhook handler). Write operations
|
|
also live here but are not called directly from outside this module —
|
|
they are wrapped by `bot.py` per §1, so that every commit, branch, and
|
|
PR carries the §6.5 On-behalf-of trailer and a row in the actions log.
|
|
|
|
This split keeps the chokepoint legible: anything that wants to read
|
|
imports from here; anything that wants to write imports from `bot.py`
|
|
and never reaches around it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from .config import Config
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class GiteaError(Exception):
|
|
def __init__(self, status: int, detail: str):
|
|
super().__init__(f"Gitea {status}: {detail}")
|
|
self.status = status
|
|
self.detail = detail
|
|
|
|
|
|
class Gitea:
|
|
def __init__(self, config: Config):
|
|
self._config = config
|
|
self._client = httpx.AsyncClient(
|
|
base_url=f"{config.gitea_url}/api/v1",
|
|
headers={
|
|
"Authorization": f"token {config.gitea_bot_token}",
|
|
"Accept": "application/json",
|
|
},
|
|
timeout=30.0,
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
await self._client.aclose()
|
|
|
|
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
resp = await self._client.request(method, path, **kwargs)
|
|
if resp.status_code >= 400:
|
|
try:
|
|
detail = resp.json().get("message", resp.text)
|
|
except Exception:
|
|
detail = resp.text
|
|
raise GiteaError(resp.status_code, detail)
|
|
return resp
|
|
|
|
# ----- Repo lifecycle -----
|
|
|
|
async def get_repo(self, owner: str, repo: str) -> dict | None:
|
|
try:
|
|
resp = await self._request("GET", f"/repos/{owner}/{repo}")
|
|
return resp.json()
|
|
except GiteaError as e:
|
|
if e.status == 404:
|
|
return None
|
|
raise
|
|
|
|
async def create_org_repo(self, org: str, name: str, *, description: str = "", private: bool = False) -> dict:
|
|
resp = await self._request(
|
|
"POST",
|
|
f"/orgs/{org}/repos",
|
|
json={
|
|
"name": name,
|
|
"description": description,
|
|
"private": private,
|
|
"auto_init": False,
|
|
},
|
|
)
|
|
return resp.json()
|
|
|
|
async def delete_repo(self, owner: str, repo: str) -> None:
|
|
await self._request("DELETE", f"/repos/{owner}/{repo}")
|
|
|
|
# ----- Branches -----
|
|
|
|
async def get_branch(self, owner: str, repo: str, branch: str) -> dict | None:
|
|
try:
|
|
resp = await self._request("GET", f"/repos/{owner}/{repo}/branches/{branch}")
|
|
return resp.json()
|
|
except GiteaError as e:
|
|
if e.status == 404:
|
|
return None
|
|
raise
|
|
|
|
async def list_branches(self, owner: str, repo: str) -> list[dict]:
|
|
resp = await self._request("GET", f"/repos/{owner}/{repo}/branches", params={"limit": 50})
|
|
return resp.json()
|
|
|
|
async def create_branch(self, owner: str, repo: str, new_branch: str, from_branch: str = "main") -> dict:
|
|
resp = await self._request(
|
|
"POST",
|
|
f"/repos/{owner}/{repo}/branches",
|
|
json={"new_branch_name": new_branch, "old_branch_name": from_branch},
|
|
)
|
|
return resp.json()
|
|
|
|
async def delete_branch(self, owner: str, repo: str, branch: str) -> None:
|
|
await self._request("DELETE", f"/repos/{owner}/{repo}/branches/{branch}")
|
|
|
|
# ----- File contents -----
|
|
|
|
async def get_contents(self, owner: str, repo: str, path: str, ref: str = "main") -> dict | None:
|
|
try:
|
|
resp = await self._request(
|
|
"GET",
|
|
f"/repos/{owner}/{repo}/contents/{path}",
|
|
params={"ref": ref},
|
|
)
|
|
return resp.json()
|
|
except GiteaError as e:
|
|
if e.status == 404:
|
|
return None
|
|
raise
|
|
|
|
async def list_dir(self, owner: str, repo: str, path: str = "", ref: str = "main") -> list[dict]:
|
|
try:
|
|
resp = await self._request(
|
|
"GET",
|
|
f"/repos/{owner}/{repo}/contents/{path}",
|
|
params={"ref": ref},
|
|
)
|
|
except GiteaError as e:
|
|
if e.status == 404:
|
|
return []
|
|
raise
|
|
data = resp.json()
|
|
return data if isinstance(data, list) else [data]
|
|
|
|
async def read_file(self, owner: str, repo: str, path: str, ref: str = "main") -> tuple[str, str] | None:
|
|
"""Return (content, sha) or None if the file is missing."""
|
|
item = await self.get_contents(owner, repo, path, ref)
|
|
if not item or item.get("type") != "file":
|
|
return None
|
|
return base64.b64decode(item["content"]).decode("utf-8"), item["sha"]
|
|
|
|
async def create_file(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
path: str,
|
|
*,
|
|
content: str,
|
|
message: str,
|
|
branch: str,
|
|
author_name: str | None = None,
|
|
author_email: str | None = None,
|
|
) -> dict:
|
|
body: dict[str, Any] = {
|
|
"message": message,
|
|
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
"branch": branch,
|
|
}
|
|
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/{path}", json=body)
|
|
return resp.json()
|
|
|
|
async def update_file(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
path: str,
|
|
*,
|
|
content: str,
|
|
sha: str,
|
|
message: str,
|
|
branch: str,
|
|
author_name: str | None = None,
|
|
author_email: str | None = None,
|
|
) -> dict:
|
|
body: dict[str, Any] = {
|
|
"message": message,
|
|
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
"sha": sha,
|
|
"branch": branch,
|
|
}
|
|
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("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=body)
|
|
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 -----
|
|
|
|
async def list_pulls(self, owner: str, repo: str, state: str = "open") -> list[dict]:
|
|
resp = await self._request(
|
|
"GET",
|
|
f"/repos/{owner}/{repo}/pulls",
|
|
params={"state": state, "limit": 50},
|
|
)
|
|
return resp.json()
|
|
|
|
async def get_pull(self, owner: str, repo: str, number: int) -> dict | None:
|
|
try:
|
|
resp = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}")
|
|
return resp.json()
|
|
except GiteaError as e:
|
|
if e.status == 404:
|
|
return None
|
|
raise
|
|
|
|
async def create_pull(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
*,
|
|
title: str,
|
|
body: str,
|
|
head: str,
|
|
base: str = "main",
|
|
) -> dict:
|
|
resp = await self._request(
|
|
"POST",
|
|
f"/repos/{owner}/{repo}/pulls",
|
|
json={"title": title, "body": body, "head": head, "base": base},
|
|
)
|
|
return resp.json()
|
|
|
|
async def merge_pull(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
number: int,
|
|
*,
|
|
merge_message_title: str,
|
|
merge_message_body: str,
|
|
style: str = "merge",
|
|
) -> None:
|
|
# Per §10.5: no-fast-forward merge commit. We pass Do='merge' so
|
|
# Gitea produces a merge commit rather than a fast-forward.
|
|
await self._request(
|
|
"POST",
|
|
f"/repos/{owner}/{repo}/pulls/{number}/merge",
|
|
json={
|
|
"Do": style,
|
|
"MergeTitleField": merge_message_title,
|
|
"MergeMessageField": merge_message_body,
|
|
},
|
|
)
|
|
|
|
async def wait_for_mergeable(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
number: int,
|
|
*,
|
|
timeout_seconds: float = 30.0,
|
|
poll_interval_seconds: float = 0.5,
|
|
) -> bool:
|
|
"""Block until Gitea finishes computing the PR's mergeability,
|
|
or the timeout expires.
|
|
|
|
Gitea computes the `mergeable` field in a background job after a
|
|
PR is opened or its head commit changes. Until that job finishes,
|
|
`mergeable` is `null` and `POST /pulls/{n}/merge` rejects with
|
|
`405 "Please try again later"`. Callers about to merge should
|
|
wait here first so the merge call doesn't race the computation.
|
|
|
|
Returns the final `mergeable` value (True / False). Raises
|
|
TimeoutError if the field never becomes non-null within the
|
|
budget — the caller can then surface a retryable error to the
|
|
operator instead of silently failing the merge step.
|
|
"""
|
|
deadline = asyncio.get_event_loop().time() + timeout_seconds
|
|
while True:
|
|
pr = await self.get_pull(owner, repo, number)
|
|
if pr is not None:
|
|
mergeable = pr.get("mergeable")
|
|
if mergeable is not None:
|
|
return bool(mergeable)
|
|
if asyncio.get_event_loop().time() >= deadline:
|
|
raise TimeoutError(
|
|
f"Gitea did not compute mergeability for "
|
|
f"{owner}/{repo}#{number} within {timeout_seconds:.1f}s"
|
|
)
|
|
await asyncio.sleep(poll_interval_seconds)
|
|
|
|
async def close_pull(self, owner: str, repo: str, number: int) -> None:
|
|
await self._request(
|
|
"PATCH",
|
|
f"/repos/{owner}/{repo}/issues/{number}",
|
|
json={"state": "closed"},
|
|
)
|
|
|
|
async def create_issue_comment(self, owner: str, repo: str, number: int, body: str) -> dict:
|
|
resp = await self._request(
|
|
"POST",
|
|
f"/repos/{owner}/{repo}/issues/{number}/comments",
|
|
json={"body": body},
|
|
)
|
|
return resp.json()
|
|
|
|
# ----- Webhooks -----
|
|
|
|
async def ensure_webhook(self, owner: str, repo: str, *, url: str, secret: str, events: list[str]) -> dict:
|
|
existing = (await self._request("GET", f"/repos/{owner}/{repo}/hooks")).json()
|
|
for hook in existing:
|
|
if hook.get("config", {}).get("url") == url:
|
|
return hook
|
|
resp = await self._request(
|
|
"POST",
|
|
f"/repos/{owner}/{repo}/hooks",
|
|
json={
|
|
"type": "gitea",
|
|
"active": True,
|
|
"events": events,
|
|
"config": {
|
|
"url": url,
|
|
"content_type": "json",
|
|
"secret": secret,
|
|
},
|
|
},
|
|
)
|
|
return resp.json()
|