From 0c54efbf6bd808bcf9051cbde282815f3a229b1f Mon Sep 17 00:00:00 2001 From: emil Date: Sun, 21 Jun 2026 21:14:17 +0000 Subject: [PATCH] GRM-40: fix: wiki links, add --strict integrity check for wiki sync (#34) --- .gitea/workflows/sync-wiki.yml | 2 +- docs/index.md | 14 ++-- scripts/ci/sync_wiki.py | 115 +++++++++++++++++++++------ tests/unit/test_sync_wiki.py | 140 +++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 30 deletions(-) diff --git a/.gitea/workflows/sync-wiki.yml b/.gitea/workflows/sync-wiki.yml index 6946b99..4807843 100644 --- a/.gitea/workflows/sync-wiki.yml +++ b/.gitea/workflows/sync-wiki.yml @@ -22,7 +22,7 @@ jobs: PYTHONPATH: src run: | . .venv/bin/activate - python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --verify + python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --strict - name: Tag wiki on release if: startsWith(github.ref, 'refs/tags/v') env: diff --git a/docs/index.md b/docs/index.md index 3060261..a222fa0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/scripts/ci/sync_wiki.py b/scripts/ci/sync_wiki.py index 52459dd..8343aaf 100644 --- a/scripts/ci/sync_wiki.py +++ b/scripts/ci/sync_wiki.py @@ -130,7 +130,9 @@ def sync_page( return "created" -def verify_wiki_page(client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]) -> bool: +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. @@ -142,6 +144,54 @@ def verify_wiki_page(client: GiteaClient, page_title: str, expected_content: str 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).") @@ -151,7 +201,13 @@ def verify_wiki_page(client: GiteaClient, page_title: str, expected_content: str 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: +@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.")) @@ -213,27 +269,42 @@ def main(dry_run: bool, repo: str | None, verify: bool) -> 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, + # --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.")) + 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 diff --git a/tests/unit/test_sync_wiki.py b/tests/unit/test_sync_wiki.py index 94e6c2c..f32e5cf 100644 --- a/tests/unit/test_sync_wiki.py +++ b/tests/unit/test_sync_wiki.py @@ -17,6 +17,7 @@ from scripts.ci.sync_wiki import ( main, read_doc_content, sync_page, + verify_wiki_integrity, verify_wiki_page, ) @@ -181,6 +182,92 @@ class TestVerifyWikiPage: 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: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.sync_wiki.MAPPING_FILE") @@ -341,3 +428,56 @@ class TestMain: 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