Compare commits

...
3 Commits
Author SHA1 Message Date
grm-ci-bot fb76ac91ef release: v0.4.0
Publish Release / publish (push) Failing after 16s
Sync Wiki / sync-wiki (push) Successful in 1m58s
2026-06-21 23:25:29 +02:00
emil 0c54efbf6b GRM-40: fix: wiki links, add --strict integrity check for wiki sync (#34) 2026-06-21 21:14:17 +00:00
emil 945344f960 GRM-39: fix: use content_base64 for Gitea wiki API, add --verify flag (#33) 2026-06-21 21:03:00 +00:00
6 changed files with 491 additions and 18 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}"
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --strict
- name: Tag wiki on release
if: startsWith(github.ref, 'refs/tags/v')
env:
+20
View File
@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file.
## [0.4.0] - 2026-06-21
### Features
- Smart CI and release skipping for workflow-only changes
### Bug Fixes
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
- Use content_base64 for Gitea wiki API, add --verify flag (#33)
- Wiki links, add --strict integrity check for wiki sync (#34)
### Other
- V0.3.2
### Refactor
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
## [0.3.2] - 2026-06-21
### Bug Fixes
+7 -7
View File
@@ -6,20 +6,20 @@ A lean command-line tool to automate the installation, configuration, and lifecy
## User Documentation
- [Getting Started](Getting-Started) — Installation, quick start, first run
- [Getting Started](Getting-Started.-) — Installation, quick start, first run
- [Installation](Installation) — Prerequisites, setup, multiple instances
- [CLI Commands](CLI-Commands) — All commands with arguments and options
- [CLI Commands](CLI-Commands.-) — All commands with arguments and options
- [Troubleshooting](Troubleshooting) — Common issues and solutions
- [FAQ](FAQ) — Frequently asked questions
## Technical Documentation
- [Architecture](Architecture) — High-level design, component interactions, data flow
- [Development Setup](Development-Setup) — Environment setup, dependencies, local testing
- [CI/CD Workflow](CI-CD-Workflow) — How CI works, release process, branch protection
- [Testing Strategy](Testing-Strategy) — Unit, integration, and Molecule tests
- [Decision Log](Decision-Log) — Key technical decisions and rationale
- [Contributing Guide](Contributing-Guide) — Coding standards, PR workflow, commit rules
- [Development Setup](Development-Setup.-) — Environment setup, dependencies, local testing
- [CI/CD Workflow](CI-CD-Workflow.-) — How CI works, release process, branch protection
- [Testing Strategy](Testing-Strategy.-) — Unit, integration, and Molecule tests
- [Decision Log](Decision-Log.-) — Key technical decisions and rationale
- [Contributing Guide](Contributing-Guide.-) — Coding standards, PR workflow, commit rules
## Quick Links
+164 -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,93 @@ 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()
def verify_wiki_integrity(
client: GiteaClient,
mapping: dict[str, str],
synced: dict[str, str],
) -> list[str]:
"""Comprehensive wiki verification.
Checks:
1. Every mapped page exists in the wiki
2. Every mapped page has non-empty content
3. Every mapped page's content matches the docs
4. No stale pages exist in the wiki (pages not in mapping)
5. Page count matches
Returns a list of failure messages (empty if all checks pass).
"""
failures: list[str] = []
existing_pages = list_wiki_pages(client)
expected_titles = set(mapping.values())
# Check 1: Page count
if len(existing_pages) != len(expected_titles):
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}")
# Check 2: Missing pages (in mapping but not in wiki)
missing = expected_titles - set(existing_pages.keys())
for title in sorted(missing):
failures.append(f"Missing page: {title}")
# Check 3: Stale pages (in wiki but not in mapping)
stale = set(existing_pages.keys()) - expected_titles
for title in sorted(stale):
failures.append(f"Stale page (not in mapping): {title}")
# Check 4: Content verification
for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
if not ok:
sub_url = existing_pages.get(page_title, "?")
actual = fetch_page_content(client, sub_url)
if not actual.strip():
failures.append(f"Empty content: {page_title}")
else:
failures.append(f"Content mismatch: {page_title}")
return failures
@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.",
)
@click.option(
"--strict",
is_flag=True,
default=False,
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
)
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
@@ -121,6 +233,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 +243,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 +258,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 +269,43 @@ def main(dry_run: bool, repo: str | None) -> None:
)
)
# --strict implies --verify
do_verify = verify or strict
if do_verify and not dry_run:
if strict:
click.echo(_("\nRunning full wiki integrity check..."))
failures = verify_wiki_integrity(client, mapping, synced)
if failures:
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures)))
for f in failures:
click.echo(f" - {f}")
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures)))
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced)))
else:
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()
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.3.2"
__version__ = "0.4.0"
+298 -2
View File
@@ -1,5 +1,6 @@
"""Unit tests for scripts/ci/sync_wiki.py."""
import base64
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -8,14 +9,47 @@ import pytest
from click.testing import CliRunner
from scripts.ci.sync_wiki import (
decode_content,
encode_content,
fetch_page_content,
list_wiki_pages,
load_mapping,
main,
read_doc_content,
sync_page,
verify_wiki_integrity,
verify_wiki_page,
)
class TestEncodeContent:
def test_encodes_utf8_to_base64(self) -> None:
result = encode_content("# Hello World")
assert result == base64.b64encode(b"# Hello World").decode("ascii")
def test_encodes_empty_string(self) -> None:
assert encode_content("") == ""
def test_encodes_unicode(self) -> None:
result = encode_content("# Café — résumé")
decoded = base64.b64decode(result).decode("utf-8")
assert decoded == "# Café — résumé"
class TestDecodeContent:
def test_decodes_base64_to_utf8(self) -> None:
encoded = base64.b64encode(b"# Hello").decode("ascii")
assert decode_content(encoded) == "# Hello"
def test_empty_string_returns_empty(self) -> None:
assert decode_content("") == ""
def test_roundtrip(self) -> None:
original = "# Wiki Page\n\nContent with **markdown**."
encoded = encode_content(original)
assert decode_content(encoded) == original
class TestLoadMapping:
def test_loads_mapping(self, tmp_path: Path) -> None:
mapping_file = tmp_path / "mapping.json"
@@ -64,6 +98,27 @@ class TestListWikiPages:
assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"}
class TestFetchPageContent:
def test_fetches_and_decodes_content(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
result = fetch_page_content(client, "Home")
assert result == "# Hello Wiki"
def test_returns_empty_on_api_error(self) -> None:
from gitea_runner_manager.exceptions import APIError
client = MagicMock()
client._request.side_effect = APIError(404, "not found")
assert fetch_page_content(client, "Missing") == ""
def test_returns_empty_for_empty_content(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = {"content_base64": ""}
assert fetch_page_content(client, "Home") == ""
class TestSyncPage:
def test_dry_run_skips(self) -> None:
client = MagicMock()
@@ -71,7 +126,7 @@ class TestSyncPage:
assert result == "skipped"
client._request.assert_not_called()
def test_creates_new_page(self) -> None:
def test_creates_new_page_with_base64(self) -> None:
client = MagicMock()
result = sync_page(client, "New-Page", "# Content", {}, dry_run=False)
assert result == "created"
@@ -79,8 +134,13 @@ class TestSyncPage:
call_args = client._request.call_args
assert call_args.args[0] == "POST"
assert call_args.args[1] == "/wiki/new"
# Verify content_base64 is used, not content
payload = call_args.kwargs["json"]
assert "content_base64" in payload
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Content"
def test_updates_existing_page(self) -> None:
def test_updates_existing_page_with_base64(self) -> None:
client = MagicMock()
existing = {"Existing-Page": "Existing-Page.-"}
result = sync_page(client, "Existing-Page", "# Updated", existing, dry_run=False)
@@ -89,6 +149,123 @@ class TestSyncPage:
call_args = client._request.call_args
assert call_args.args[0] == "PATCH"
assert "/wiki/page/Existing-Page.-" in call_args.args[1]
# Verify content_base64 is used
payload = call_args.kwargs["json"]
assert "content_base64" in payload
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
class TestVerifyWikiPage:
def test_verifies_matching_content(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# Hello Wiki", existing) is True
def test_fails_on_mismatch(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Old Content").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# New Content", existing) is False
def test_fails_on_empty_wiki_content(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = {"content_base64": ""}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# Expected", existing) is False
def test_fails_when_page_not_in_existing(self) -> None:
client = MagicMock()
assert verify_wiki_page(client, "Missing", "# Content", {}) is False
class TestVerifyWikiIntegrity:
def _make_client(self, pages: dict[str, str], contents: dict[str, str]) -> MagicMock:
"""Create a mock client that returns the given pages and contents."""
client = MagicMock()
# list_wiki_pages calls GET /wiki/pages
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
# fetch_page_content calls GET /wiki/page/{sub_url}
def mock_request(method, path, **kwargs):
resp = MagicMock()
if path == "/wiki/pages":
resp.json.return_value = page_list
elif path.startswith("/wiki/page/"):
sub_url = path.replace("/wiki/page/", "")
content = contents.get(sub_url, "")
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
resp.json.return_value = {"content_base64": encoded}
return resp
client._request.side_effect = mock_request
return client
def test_all_good_no_failures(self) -> None:
pages = {"Home": "Home", "FAQ": "FAQ"}
contents = {"Home": "# Home", "FAQ": "# FAQ"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
def test_missing_page_detected(self) -> None:
pages = {"Home": "Home"} # FAQ missing from wiki
contents = {"Home": "# Home"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Missing page: FAQ" in f for f in failures)
def test_stale_page_detected(self) -> None:
pages = {"Home": "Home", "Old-Page": "Old-Page"} # Old-Page not in mapping
contents = {"Home": "# Home", "Old-Page": "# Old"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Stale page" in f and "Old-Page" in f for f in failures)
def test_page_count_mismatch_detected(self) -> None:
pages = {"Home": "Home", "Extra": "Extra"}
contents = {"Home": "# Home", "Extra": "# Extra"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Page count mismatch" in f for f in failures)
def test_empty_content_detected(self) -> None:
pages = {"Home": "Home"}
contents = {"Home": ""} # Empty content
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Expected Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Empty content: Home" in f for f in failures)
def test_content_mismatch_detected(self) -> None:
pages = {"Home": "Home"}
contents = {"Home": "# Wrong Content"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Correct Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Content mismatch: Home" in f for f in failures)
def test_multiple_failures_all_reported(self) -> None:
pages = {"Home": "Home", "Stale": "Stale"}
contents = {"Home": "", "Stale": "# Stale"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"} # FAQ missing
synced = {"Home": "# Home Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
class TestMain:
@@ -168,6 +345,21 @@ class TestMain:
assert "not found" in result.output
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files are skipped with a warning."""
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
with patch("scripts.ci.sync_wiki.read_doc_content", return_value=" \n "):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "empty" in result.output.lower()
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_create_and_update(self, mock_client_cls: MagicMock) -> None:
@@ -185,3 +377,107 @@ class TestMain:
assert result.exit_code == 0
assert "Created: 1" in result.output
assert "Updated: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify passes when content matches."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
encoded = base64.b64encode(b"# Home Content").decode("ascii")
# list_wiki_pages returns {"Home": "Home"}, fetch returns encoded content
mock_client._request.return_value.json.return_value = {"content_base64": encoded}
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=True):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 0
assert "Verification passed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify fails when wiki pages have empty content."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=False):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 1
assert "FAIL" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify is skipped during dry-run."""
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--verify", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Verification" not in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict passes when integrity check succeeds."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("scripts.ci.sync_wiki.verify_wiki_integrity", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
assert result.exit_code == 0
assert "Integrity check passed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict fails when integrity check finds issues."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch(
"scripts.ci.sync_wiki.verify_wiki_integrity",
return_value=["Missing page: FAQ", "Stale page: Old-Page"],
):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
assert result.exit_code == 1
assert "Integrity check FAILED" in result.output
assert "Missing page: FAQ" in result.output
assert "Stale page: Old-Page" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict verification is skipped during dry-run."""
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Integrity check" not in result.output