GRM-39: fix: use content_base64 for Gitea wiki API, add --verify flag (#33)

This commit is contained in:
2026-06-21 21:03:00 +00:00
parent 713e752860
commit 945344f960
3 changed files with 252 additions and 10 deletions
+93 -7
View File
@@ -6,18 +6,20 @@ map file paths to wiki page titles, and creates/updates wiki pages via the
Gitea API. Pages that exist in the wiki but not in the mapping are left
untouched (not deleted).
Gitea 1.26 wiki API endpoints:
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content, message}
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content, message}
Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content_base64, message}
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, message}
- List: GET /repos/{owner}/{repo}/wiki/pages → [{title, sub_url, ...}]
- Fetch: GET /repos/{owner}/{repo}/wiki/page/{sub_url}{title, content_base64, ...}
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
Usage:
REPO_TOKEN=<token> python3 scripts/sync_wiki.py [--dry-run] [--repo owner/repo]
REPO_TOKEN=<token> python3 scripts/ci/sync_wiki.py [--dry-run] [--repo owner/repo]
"""
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
@@ -49,6 +51,23 @@ def read_doc_content(file_path: str) -> str:
return f.read()
def encode_content(content: str) -> str:
"""Encode content as base64 for the Gitea wiki API.
The Gitea wiki API requires content_base64, not plain content.
Sending plain content silently fails (pages are created/updated
but with empty content).
"""
return base64.b64encode(content.encode("utf-8")).decode("ascii")
def decode_content(content_b64: str) -> str:
"""Decode base64 content from the Gitea wiki API."""
if not content_b64:
return ""
return base64.b64decode(content_b64).decode("utf-8")
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
"""List existing wiki pages, returning {title: sub_url}."""
try:
@@ -58,6 +77,15 @@ def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
def fetch_page_content(client: GiteaClient, sub_url: str) -> str:
"""Fetch a wiki page's content by sub_url, decoded from base64."""
try:
page = client._request("GET", f"/wiki/page/{sub_url}").json()
return decode_content(page.get("content_base64", ""))
except APIError:
return ""
def sync_page(
client: GiteaClient,
page_title: str,
@@ -73,13 +101,19 @@ def sync_page(
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
return "skipped"
content_b64 = encode_content(content)
if page_title in existing_pages:
# Update existing page via PATCH
sub_url = existing_pages[page_title]
client._request(
"PATCH",
f"/wiki/page/{sub_url}",
json={"title": page_title, "content": content, "message": f"Sync from docs/ — update {page_title}"},
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — update {page_title}",
},
)
return "updated"
@@ -87,15 +121,37 @@ def sync_page(
client._request(
"POST",
"/wiki/new",
json={"title": page_title, "content": content, "message": f"Sync from docs/ — create {page_title}"},
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — create {page_title}",
},
)
return "created"
def verify_wiki_page(client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]) -> bool:
"""Verify that a wiki page has non-empty content matching the docs.
Returns True if the page content matches, False otherwise.
"""
if page_title not in existing_pages:
return False
sub_url = existing_pages[page_title]
actual = fetch_page_content(client, sub_url)
return actual.strip() == expected_content.strip()
@click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
@click.option("--repo", default=None, help="Repository in owner/name format (auto-detected if omitted).")
def main(dry_run: bool, repo: str | None) -> None:
@click.option(
"--verify",
is_flag=True,
default=False,
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.",
)
def main(dry_run: bool, repo: str | None, verify: bool) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
@@ -121,6 +177,7 @@ def main(dry_run: bool, repo: str | None) -> None:
created = 0
updated = 0
skipped = 0
synced: dict[str, str] = {} # title -> content, for verification
for file_path, page_title in sorted(mapping.items()):
try:
@@ -130,6 +187,11 @@ def main(dry_run: bool, repo: str | None) -> None:
skipped += 1
continue
if not content.strip():
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
skipped += 1
continue
result = sync_page(client, page_title, content, existing_pages, dry_run)
if result == "created":
created += 1
@@ -140,6 +202,8 @@ def main(dry_run: bool, repo: str | None) -> None:
else:
skipped += 1
synced[page_title] = content
click.echo(
_(
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
@@ -149,6 +213,28 @@ def main(dry_run: bool, repo: str | None) -> None:
)
)
if verify and not dry_run:
click.echo(_("\nVerifying wiki pages have content..."))
# Re-fetch the page list to get updated sub_urls
existing_pages = list_wiki_pages(client)
failures = 0
for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
if ok:
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content)))
else:
click.echo(_(" FAIL: {title} — content mismatch or empty!", title=page_title))
failures += 1
if failures > 0:
click.echo(
_(
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
failures=failures,
)
)
raise click.ClickException(_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures))
click.echo(_("\nVerification passed — all wiki pages have correct content."))
if __name__ == "__main__": # pragma: no cover
main()