diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc4a5f..53c98e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,8 @@ jobs: if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Restore Discourse sync cache uses: actions/cache/restore@v4 @@ -87,41 +89,97 @@ jobs: pip install uv uv pip install --system zensical + - name: Compute docs affected by this PR + id: diff + env: + BASE_REF: ${{ github.base_ref }} + DOCS_SYNC_REF_NAME: ${{ github.base_ref }} + DISCOURSE_URL: ${{ secrets.DISCOURSE_URL }} + run: | + set -euo pipefail + # GHA reserves GITHUB_REF_NAME; unset so base worktree's + # pre-DOCS_SYNC_REF_NAME sync_to_discourse.py also falls back to master. + unset GITHUB_REF_NAME + + git fetch --depth=1 origin "$BASE_REF" + BASE_SHA=$(git rev-parse "origin/$BASE_REF") + echo "Base: $BASE_REF ($BASE_SHA)" + + uv run --python 3.12 python tools/render_docs.py > /tmp/head_hashes.json + + WORKTREE=$(mktemp -d) + git worktree add --detach "$WORKTREE" "$BASE_SHA" + cp tools/render_docs.py "$WORKTREE/tools/render_docs.py" + (cd "$WORKTREE" && uv run --python 3.12 python tools/render_docs.py > /tmp/base_hashes.json) + git worktree remove --force "$WORKTREE" + + python3 - <<'PY' > /tmp/affected.txt + import json + h = json.load(open('/tmp/head_hashes.json')) + b = json.load(open('/tmp/base_hashes.json')) + for p in sorted(set(h) | set(b)): + if h.get(p) != b.get(p): + print(p) + PY + + AFFECTED=$(cat /tmp/affected.txt || true) + echo "AFFECTED_DOCS:" + printf '%s\n' "$AFFECTED" + + ONLY_ARGS=$(printf '%s ' $AFFECTED) + ONLY_ARGS=${ONLY_ARGS% } + DOC_COUNT=$([ -n "$AFFECTED" ] && printf '%s\n' "$AFFECTED" | wc -l | tr -d ' ' || echo 0) + ANY_CHANGE=$([ -n "$AFFECTED" ] && echo 1 || echo "") + + { + echo "any_change=$ANY_CHANGE" + echo "only_args=$ONLY_ARGS" + echo "doc_count=$DOC_COUNT" + } >> "$GITHUB_OUTPUT" + - name: Dry-run Discourse sync + if: steps.diff.outputs.any_change == '1' env: DISCOURSE_URL: ${{ secrets.DISCOURSE_URL }} DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_API_KEY }} DISCOURSE_API_USER: ${{ secrets.DISCOURSE_API_USER }} DISCOURSE_CATEGORY_MAP: ${{ vars.DISCOURSE_CATEGORY_MAP }} + ONLY_ARGS: ${{ steps.diff.outputs.only_args }} + DOCS_SYNC_REF_NAME: ${{ github.base_ref }} + run: | + uv run --python 3.12 python tools/sync_to_discourse.py \ + --dry-run --verbose --only $ONLY_ARGS 2>&1 | tee sync_preview.txt + + - name: Write no-change preview + if: steps.diff.outputs.any_change != '1' run: | - uv run --python 3.12 python tools/sync_to_discourse.py --dry-run --verbose 2>&1 | tee sync_preview.txt + cat > sync_preview.txt <<'EOF' + No documentation or sync tooling changes detected in this PR. + Skipping Discourse dry-run. + EOF - name: Build PR comment id: comment + env: + ANY_CHANGE: ${{ steps.diff.outputs.any_change }} + DOC_COUNT: ${{ steps.diff.outputs.doc_count }} run: | - # Extract the summary table (everything after the === line) - SUMMARY=$(sed -n '/^=\{10,\}/,/^=\{10,\}$/p' sync_preview.txt | head -20) - - # Count actions from the summary CREATED=$(grep -oP 'Created:\s+\K\d+' sync_preview.txt || echo "0") UPDATED=$(grep -oP 'Updated \(Normal\):\s+\K\d+' sync_preview.txt || echo "0") - UPDATED_IDX=$(grep -oP 'Updated \(Index\):\s+\K\d+' sync_preview.txt || echo "0") - SKIPPED_CACHE=$(grep -oP 'Skipped \(Cached\):\s+\K\d+' sync_preview.txt || echo "0") - SKIPPED_MATCH=$(grep -oP 'Skipped \(Discourse Match\):\s+\K\d+' sync_preview.txt || echo "0") - SKIPPED_CAT=$(grep -oP 'Skipped \(No Category\):\s+\K\d+' sync_preview.txt || echo "0") + SIDEBARS=$(grep -oP 'Sidebars Updated:\s+\K\d+' sync_preview.txt || echo "0") FAILED=$(grep -oP 'Failed:\s+\K\d+' sync_preview.txt || echo "0") - TOTAL=$(grep -oP 'Total Parsed:\s+\K\d+' sync_preview.txt || echo "0") - - CHANGES=$((CREATED + UPDATED + UPDATED_IDX)) + CHANGES=$((CREATED + UPDATED + SIDEBARS)) { echo 'body< int | None: return None return posts[0].get("id") + def get_topic(self, topic_id: int) -> dict[str, Any] | None: + """Fetch topic metadata (title, category_id, post_stream).""" + return self._get(f"/t/{topic_id}.json") + def get_post_raw(self, post_id: int) -> str | None: """Fetch the current raw markdown content of a post. diff --git a/tools/render_docs.py b/tools/render_docs.py new file mode 100644 index 0000000..158afff --- /dev/null +++ b/tools/render_docs.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Render each nav doc and emit {path: sha256} JSON. + +Used by the PR preview CI job to detect which docs render differently +between the base branch and the PR HEAD without touching Discourse. +Link rewriting uses an empty topic_id_map so the hash is reproducible +across refs. +""" + +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from converter import convert +from nav_parser import parse_all +from sync_to_discourse import DOCS_DIR, ZENSICAL_TOML, build_footer + + +def main() -> None: + out: dict[str, str] = {} + for entry in parse_all(ZENSICAL_TOML): + fp = DOCS_DIR / entry.path + if not fp.exists(): + continue + converted = convert( + fp.read_text(encoding="utf-8"), + file_path=entry.path, topic_id_map={}, + ) + body = converted.rstrip("\n") + build_footer(entry.path) + out[entry.path] = hashlib.sha256(body.encode("utf-8")).hexdigest() + json.dump(out, sys.stdout, sort_keys=True) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/tools/sync_to_discourse.py b/tools/sync_to_discourse.py index eb2c662..3c2f732 100644 --- a/tools/sync_to_discourse.py +++ b/tools/sync_to_discourse.py @@ -35,7 +35,16 @@ REPO_ROOT = Path(__file__).resolve().parent.parent DOCS_DIR = REPO_ROOT / "docs" ZENSICAL_TOML = REPO_ROOT / "zensical.toml" -GITHUB_REF_NAME = os.environ.get("GITHUB_REF_NAME", "master") + + +# DOCS_SYNC_REF_NAME overrides GITHUB_REF_NAME because GitHub Actions +# reserves GITHUB_* and ignores step-level env overrides in PR runs. +def _resolve_ref_name() -> str: + return ( + os.environ.get("DOCS_SYNC_REF_NAME") + or os.environ.get("GITHUB_REF_NAME") + or "master" + ) SKIP_INDEX_FILES = frozenset({"index.md", "README.md"}) @@ -94,11 +103,32 @@ def _normalize(s: str) -> str: return _normalize(local_body) == _normalize(remote_raw) +def _post_body_matches_remote( + client: DiscourseClient, post_id: int, local_body: str, +) -> bool: + time.sleep(1.0) + return _body_matches_discourse(local_body, client.get_post_raw(post_id)) + + +def _topic_metadata_differs( + client: DiscourseClient, topic_id: int, + new_title: str, new_category_id: int, +) -> bool: + time.sleep(1.0) + topic = client.get_topic(topic_id) + if topic is None: + return True + return ( + (topic.get("title") or "").strip() != new_title.strip() + or topic.get("category_id") != new_category_id + ) + + def build_footer(doc_path: str) -> str: """Build the sync footer with GitHub link and sync ID.""" gh_url = ( f"https://github.com/sunnypilot/user-docs/blob/" - f"{GITHUB_REF_NAME}/docs/{doc_path}" + f"{_resolve_ref_name()}/docs/{doc_path}" ) return ( "\n\n---\n" @@ -332,14 +362,6 @@ def _generate_sidebars( # Discourse requires at least one paragraph in category descriptions combined = f"Browse the **{section_title}** documentation:\n\n{sidebar_content}\n" - if dry_run: - print( - f" [DRY RUN] Would update sidebar: {section_title} " - f"(category {category_id}, {len(sidebar_lines)} items)" - ) - stats["sidebars_updated"] += 1 - continue - about_topic_id = client.get_category_about_topic_id(category_id) if about_topic_id is None: print( @@ -356,6 +378,23 @@ def _generate_sidebars( ) continue + if _post_body_matches_remote(client, post_id, combined): + if verbose: + print( + f" SKIP sidebar (discourse up-to-date): " + f"{section_title} (category {category_id})" + ) + stats["skipped_sidebar_match"] += 1 + continue + + if dry_run: + print( + f" [DRY RUN] Would update sidebar: {section_title} " + f"(category {category_id}, {len(sidebar_lines)} items)" + ) + stats["sidebars_updated"] += 1 + continue + time.sleep(1.0) result = client.update_post( post_id, combined, @@ -458,32 +497,52 @@ def sync_docs(args: argparse.Namespace) -> None: client = DiscourseClient(config) cache = ContentCache() - entries = parse_all(ZENSICAL_TOML) + all_entries = parse_all(ZENSICAL_TOML) + + raw_only = getattr(args, "only", None) + only_paths = {p.strip() for p in raw_only if p.strip()} if raw_only else None + + if only_paths is not None: + entries = [e for e in all_entries if e.path in only_paths] + missing = sorted(only_paths - {e.path for e in all_entries}) + if missing: + print(f" WARN: --only paths not in zensical.toml, ignoring: {missing}") + else: + entries = all_entries + + # Sidebar gen requires the full index_contents map; filtered runs skip it. + skip_sidebars = only_paths is not None stats: dict[str, int] = { "total": len(entries), "created": 0, "updated": 0, - "updated_index": 0, "skipped_cached": 0, "skipped_discourse_match": 0, "skipped_no_category": 0, "failed": 0, "sidebars_updated": 0, + "skipped_sidebar_match": 0, + "skipped_metadata_match": 0, } # Store converted index.md content for sidebar combination index_contents: dict[str, str] = {} - print(f"Sync: {len(entries)} entries from zensical.toml") + filter_note = f" (filtered to {len(entries)} of {len(all_entries)})" if only_paths else "" + print(f"Sync: {len(entries)} entries from zensical.toml{filter_note}") print(f" Discourse: {config.base_url}") print(f" Dry run: {args.dry_run}") print(f" Force: {args.force}") + if only_paths is not None: + print(f" Only: {sorted(only_paths)}") print() # --- Pass 1: Resolve all topic IDs --- + # Resolve the full nav so link rewriting and sidebar lookups still work + # when the caller supplied a subset via --only. synced_topics = _resolve_all_topic_ids( - entries, config=config, client=client, verbose=args.verbose, + all_entries, config=config, client=client, verbose=args.verbose, ) # --- Pass 2: Convert and push --- @@ -536,68 +595,11 @@ def sync_docs(args: argparse.Namespace) -> None: ) if is_about_topic: - # Index files update the "About" topic for their category. - # Each index.md maps to the most specific matching category - # (e.g. features/cruise/index.md -> sub-category 140). - about_topic_id = synced_topics.get(entry.path) - if args.dry_run: - if about_topic_id is not None: - print( - f" [DRY RUN] Would update index: {label}\n" - f" -> About topic {about_topic_id} " - f"(category {category_id})\n" - f" -> {config.base_url}/t/{about_topic_id}" - ) - else: - print( - f" [DRY RUN] WARN: No About topic for " - f"category {category_id}: {label}" - ) - stats["updated_index"] += 1 - else: - if about_topic_id is None: - print( - f" FAIL: No About topic for " - f"category {category_id}: {label}" - ) - stats["failed"] += 1 - continue - - post_id = client.first_post_id(about_topic_id) - if post_id is None: - print( - f" FAIL: No first post for " - f"About topic {about_topic_id}: {label}" - ) - stats["failed"] += 1 - continue - - time.sleep(1.0) - current_raw = client.get_post_raw(post_id) - if _body_matches_discourse(body, current_raw): - if args.verbose: - print(f" SKIP (discourse up-to-date): {label}") - stats["skipped_discourse_match"] += 1 - cache.save(entry.path, raw_content) - continue - - time.sleep(1.0) - result = client.update_post( - post_id, body, - edit_reason="Docs sync: index page", - ) - if result is None: - print( - f" FAIL: Could not update " - f"About topic {about_topic_id}: {label}" - ) - stats["failed"] += 1 - continue - - print(f" Updated index: {label} -> About topic {about_topic_id}") - stats["updated_index"] += 1 - - # Update cache for index pages + # About topics are owned by _generate_sidebars (body + sidebar lines). + # Updating here would be clobbered and the body-only idempotency check + # would always diverge on the sidebar block. + if args.verbose: + print(f" DEFER to sidebar gen: {label}") if not args.dry_run: cache.save(entry.path, raw_content) continue @@ -606,6 +608,20 @@ def sync_docs(args: argparse.Namespace) -> None: topic_id = synced_topics.get(entry.path) if topic_id is not None: + post_id = client.first_post_id(topic_id) + if post_id is None: + print(f" FAIL: No first post for topic {topic_id}: {label}") + stats["failed"] += 1 + continue + + if _post_body_matches_remote(client, post_id, body): + if args.verbose: + print(f" SKIP (discourse up-to-date): {label}") + stats["skipped_discourse_match"] += 1 + if not args.dry_run: + cache.save(entry.path, raw_content) + continue + if args.dry_run: print( f" [DRY RUN] Would update: {label}\n" @@ -615,21 +631,6 @@ def sync_docs(args: argparse.Namespace) -> None: ) stats["updated"] += 1 else: - post_id = client.first_post_id(topic_id) - if post_id is None: - print(f" FAIL: No first post for topic {topic_id}: {label}") - stats["failed"] += 1 - continue - - time.sleep(1.0) - current_raw = client.get_post_raw(post_id) - if _body_matches_discourse(body, current_raw): - if args.verbose: - print(f" SKIP (discourse up-to-date): {label}") - stats["skipped_discourse_match"] += 1 - cache.save(entry.path, raw_content) - continue - time.sleep(1.0) result = client.update_post( post_id, body, @@ -640,12 +641,11 @@ def sync_docs(args: argparse.Namespace) -> None: stats["failed"] += 1 continue - # Update topic title and category - time.sleep(1.0) - client.update_topic( - topic_id, title, - category_id=category_id, - ) + if _topic_metadata_differs(client, topic_id, title, category_id): + time.sleep(1.0) + client.update_topic(topic_id, title, category_id=category_id) + else: + stats["skipped_metadata_match"] += 1 print(f" Updated: {label} -> topic {topic_id}") stats["updated"] += 1 @@ -660,6 +660,22 @@ def sync_docs(args: argparse.Namespace) -> None: found_id = existing_by_title["id"] synced_topics[entry.path] = found_id + post_id = client.first_post_id(found_id) + if post_id is None: + print( + f" FAIL: No first post for topic {found_id}: {label}" + ) + stats["failed"] += 1 + continue + + if _post_body_matches_remote(client, post_id, body): + if args.verbose: + print(f" SKIP (discourse up-to-date): {label}") + stats["skipped_discourse_match"] += 1 + if not args.dry_run: + cache.save(entry.path, raw_content) + continue + if args.dry_run: print( f" [DRY RUN] Would update (found by title): " @@ -670,23 +686,6 @@ def sync_docs(args: argparse.Namespace) -> None: ) stats["updated"] += 1 else: - post_id = client.first_post_id(found_id) - if post_id is None: - print( - f" FAIL: No first post for topic {found_id}: {label}" - ) - stats["failed"] += 1 - continue - - time.sleep(1.0) - current_raw = client.get_post_raw(post_id) - if _body_matches_discourse(body, current_raw): - if args.verbose: - print(f" SKIP (discourse up-to-date): {label}") - stats["skipped_discourse_match"] += 1 - cache.save(entry.path, raw_content) - continue - time.sleep(1.0) result = client.update_post( post_id, body, @@ -699,11 +698,11 @@ def sync_docs(args: argparse.Namespace) -> None: stats["failed"] += 1 continue - time.sleep(1.0) - client.update_topic( - found_id, title, - category_id=category_id, - ) + if _topic_metadata_differs(client, found_id, title, category_id): + time.sleep(1.0) + client.update_topic(found_id, title, category_id=category_id) + else: + stats["skipped_metadata_match"] += 1 print( f" Updated (found by title): {label} -> topic {found_id}" @@ -740,16 +739,15 @@ def sync_docs(args: argparse.Namespace) -> None: # --- Sidebar generation --- print() - print("Generating sidebars...") - _generate_sidebars( - config=config, - client=client, - synced_topics=synced_topics, - index_contents=index_contents, - dry_run=args.dry_run, - verbose=args.verbose, - stats=stats, - ) + if skip_sidebars: + print("Skipping sidebar generation (filtered run)") + else: + print("Generating sidebars...") + _generate_sidebars( + config=config, client=client, synced_topics=synced_topics, + index_contents=index_contents, dry_run=args.dry_run, + verbose=args.verbose, stats=stats, + ) # --- Summary --- print() @@ -759,12 +757,13 @@ def sync_docs(args: argparse.Namespace) -> None: print(f" Total Parsed: {stats['total']}") print(f" Created: {stats['created']}") print(f" Updated (Normal): {stats['updated']}") - print(f" Updated (Index): {stats['updated_index']}") print(f" Skipped (Cached): {stats['skipped_cached']}") print(f" Skipped (Discourse Match): {stats['skipped_discourse_match']}") + print(f" Skipped (Metadata Match): {stats['skipped_metadata_match']}") print(f" Skipped (No Category): {stats['skipped_no_category']}") print(f" Failed: {stats['failed']}") print(f" Sidebars Updated: {stats['sidebars_updated']}") + print(f" Sidebars Skipped (Match): {stats['skipped_sidebar_match']}") print("=" * 60) if stats["failed"] > 0: @@ -776,6 +775,10 @@ def main() -> None: parser.add_argument("--dry-run", action="store_true", help="Preview changes without making API calls.") parser.add_argument("--verbose", "-v", action="store_true", help="Print detailed progress for skipped entries.") parser.add_argument("--force", action="store_true", help="Bypass content cache and re-sync all entries.") + parser.add_argument( + "--only", nargs="+", metavar="PATH", + help="Restrict Pass 2 to these doc paths; skips sidebar gen.", + ) args = parser.parse_args() sync_docs(args) diff --git a/tools/test_sync_to_discourse.py b/tools/test_sync_to_discourse.py index cb5e736..ebb8b4d 100644 --- a/tools/test_sync_to_discourse.py +++ b/tools/test_sync_to_discourse.py @@ -5,6 +5,8 @@ """ import argparse +import contextlib +import io import json import sys import tempfile @@ -463,6 +465,198 @@ def side_effect(req: object) -> MagicMock: print(" PASS: sync_docs_skips_when_discourse_matches") +# --------------------------------------------------------------------------- +# --only filter + idempotency guarantees +# --------------------------------------------------------------------------- + +_ENV = { + "DISCOURSE_URL": "https://community.sunnypilot.ai", + "DISCOURSE_API_KEY": "test-key", + "DISCOURSE_CATEGORY_MAP": '{"getting-started": 133}', +} + +_TWO_DOC_TOML = textwrap.dedent("""\ + [project] + docs_dir = "docs" + nav = [ + { "Getting Started" = [ + { "Doc A" = "getting-started/doc-a.md" }, + { "Doc B" = "getting-started/doc-b.md" }, + ]}, + ] +""") + + +def _build_two_doc_workspace(tmpdir: str) -> tuple[Path, Path]: + docs = Path(tmpdir) / "docs" / "getting-started" + docs.mkdir(parents=True) + (docs / "doc-a.md").write_text("---\ntitle: Doc A\n---\n\n# Doc A\n\nAlpha.\n") + (docs / "doc-b.md").write_text("---\ntitle: Doc B\n---\n\n# Doc B\n\nBravo.\n") + toml = Path(tmpdir) / "zensical.toml" + toml.write_text(_TWO_DOC_TOML) + return docs.parent, toml + + +def _run_sync(tmpdir, docs_dir, toml_path, args, side_effect): + from content_cache import ContentCache + cache = ContentCache(cache_dir=Path(tmpdir) / ".cache") + with ( + patch.dict("os.environ", _ENV, clear=False), + patch("sync_to_discourse.DOCS_DIR", docs_dir), + patch("sync_to_discourse.ZENSICAL_TOML", toml_path), + patch("sync_to_discourse.ContentCache", lambda: cache), + patch("sync_to_discourse.time") as mock_time, + patch("urllib.request.urlopen") as mock_urlopen, + ): + mock_time.sleep = MagicMock() + mock_urlopen.side_effect = side_effect + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + sync_docs(args) + return buf.getvalue(), mock_urlopen + + +def test_sync_docs_only_filter_restricts_pass2(): + with tempfile.TemporaryDirectory() as tmpdir: + docs_dir, toml_path = _build_two_doc_workspace(tmpdir) + args = argparse.Namespace( + dry_run=True, verbose=True, force=True, + only=["getting-started/doc-a.md"], + ) + output, _ = _run_sync( + tmpdir, docs_dir, toml_path, args, + lambda req: _mock_response({"topics": []}), + ) + + assert "doc-a.md" in output + pass2 = output.split("Pass 2:", 1)[1].split("Sync Summary", 1)[0] + assert "doc-b.md" not in pass2 + assert "Skipping sidebar generation" in output + print(" PASS: sync_docs_only_filter_restricts_pass2") + + +def test_sync_docs_only_filter_warns_on_unknown_path(): + with tempfile.TemporaryDirectory() as tmpdir: + docs_dir, toml_path = _build_two_doc_workspace(tmpdir) + args = argparse.Namespace( + dry_run=True, verbose=True, force=True, + only=["getting-started/doc-a.md", "getting-started/ghost.md"], + ) + output, _ = _run_sync( + tmpdir, docs_dir, toml_path, args, + lambda req: _mock_response({"topics": []}), + ) + + assert "WARN: --only paths not in zensical.toml" in output + assert "getting-started/ghost.md" in output + print(" PASS: sync_docs_only_filter_warns_on_unknown_path") + + +def test_sync_docs_dry_run_counts_match_as_skip_not_update(): + def side_effect(req): + url, method = req.full_url, req.method + if method == "GET" and "/search.json" in url: + return _mock_response({"topics": [{"id": 500, "title": "Doc A"}]}) + if method == "GET" and url.endswith("/t/500.json"): + return _mock_response({ + "id": 500, "title": "Doc A", "category_id": 133, + "post_stream": {"posts": [{"id": 900, "post_number": 1}]}, + }) + if method == "GET" and url.endswith("/posts/900.json"): + return _mock_response({"id": 900, "raw": "whatever"}) + return _mock_response({}) + + with tempfile.TemporaryDirectory() as tmpdir: + docs_dir, toml_path = _build_two_doc_workspace(tmpdir) + args = argparse.Namespace( + dry_run=True, verbose=True, force=True, + only=["getting-started/doc-a.md"], + ) + with patch("sync_to_discourse._body_matches_discourse", return_value=True): + output, _ = _run_sync(tmpdir, docs_dir, toml_path, args, side_effect) + + assert "Would update" not in output + assert "SKIP (discourse up-to-date)" in output + assert "Skipped (Discourse Match): 1" in output + print(" PASS: sync_docs_dry_run_counts_match_as_skip_not_update") + + +def test_sidebar_skips_update_when_remote_matches(): + from sync_to_discourse import _generate_sidebars + + with tempfile.TemporaryDirectory() as tmpdir: + docs_dir, toml_path = _build_two_doc_workspace(tmpdir) + (docs_dir / "getting-started" / "index.md").write_text("# GS\n\nIntro.\n") + config = DiscourseConfig( + base_url=_ENV["DISCOURSE_URL"], api_key="k", + category_mapping={"getting-started": 133}, + ) + stats = {"sidebars_updated": 0, "skipped_sidebar_match": 0} + + def side_effect(req): + url, method = req.full_url, req.method + if method == "GET" and "/c/133/show.json" in url: + return _mock_response({"category": {"topic_url": "/t/x/700"}}) + if method == "GET" and url.endswith("/t/700.json"): + return _mock_response({"post_stream": {"posts": [{"id": 800}]}}) + if method == "GET" and url.endswith("/posts/800.json"): + return _mock_response({"id": 800, "raw": "matches"}) + raise AssertionError(f"Unexpected {method} {url}") + + with ( + patch.dict("os.environ", _ENV, clear=False), + patch("sync_to_discourse.ZENSICAL_TOML", toml_path), + patch("sync_to_discourse.time") as mock_time, + patch("urllib.request.urlopen") as mock_urlopen, + patch("sync_to_discourse._body_matches_discourse", return_value=True), + ): + mock_time.sleep = MagicMock() + mock_urlopen.side_effect = side_effect + _generate_sidebars( + config=config, client=DiscourseClient(config), + synced_topics={ + "getting-started/doc-a.md": 501, + "getting-started/doc-b.md": 502, + }, + index_contents={"getting-started/index.md": "Intro.\n"}, + dry_run=False, verbose=True, stats=stats, + ) + + assert stats == {"sidebars_updated": 0, "skipped_sidebar_match": 1} + print(" PASS: sidebar_skips_update_when_remote_matches") + + +def test_update_topic_skipped_when_metadata_matches(): + calls: list[tuple[str, str]] = [] + + def side_effect(req): + url, method = req.full_url, req.method + calls.append((method, url)) + if method == "GET" and "/search.json" in url: + return _mock_response({"topics": [{"id": 500, "title": "Doc A"}]}) + if method == "GET" and url.endswith("/t/500.json"): + return _mock_response({ + "id": 500, "title": "Doc A", "category_id": 133, + "post_stream": {"posts": [{"id": 900}]}, + }) + if method == "GET" and url.endswith("/posts/900.json"): + return _mock_response({"id": 900, "raw": "body-differs"}) + return _mock_response({"post": {"id": 900}}) + + with tempfile.TemporaryDirectory() as tmpdir: + docs_dir, toml_path = _build_two_doc_workspace(tmpdir) + args = argparse.Namespace( + dry_run=False, verbose=True, force=True, + only=["getting-started/doc-a.md"], + ) + with patch("sync_to_discourse._body_matches_discourse", return_value=False): + _run_sync(tmpdir, docs_dir, toml_path, args, side_effect) + + assert any(m == "PUT" and "/posts/" in u for m, u in calls) + assert not any(m == "PUT" and "/t/-/" in u for m, u in calls) + print(" PASS: update_topic_skipped_when_metadata_matches") + + # --------------------------------------------------------------------------- # Runner # --------------------------------------------------------------------------- @@ -503,6 +697,12 @@ def side_effect(req: object) -> MagicMock: # Integration test_sync_docs_dry_run, test_sync_docs_skips_when_discourse_matches, + # --only filter + idempotency + test_sync_docs_only_filter_restricts_pass2, + test_sync_docs_only_filter_warns_on_unknown_path, + test_sync_docs_dry_run_counts_match_as_skip_not_update, + test_sidebar_skips_update_when_remote_matches, + test_update_topic_skipped_when_metadata_matches, ] passed = 0 failed = 0