diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 7bea544..5549034 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -169,7 +169,10 @@ jobs: if: needs.detect-type.outputs.is-release == 'false' runs-on: docker container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest - timeout-minutes: 10 + timeout-minutes: 15 + concurrency: + group: sync-wiki-${{ github.repository }} + cancel-in-progress: false defaults: run: shell: bash diff --git a/src/devx/ci/sync_wiki.py b/src/devx/ci/sync_wiki.py index 1af84f2..c3ba9f6 100644 --- a/src/devx/ci/sync_wiki.py +++ b/src/devx/ci/sync_wiki.py @@ -122,6 +122,9 @@ def sync_page( """Create or update a single wiki page. Returns "created", "updated", or "skipped" (if dry-run). + + If a create fails with HTTP 400 "already exists" (the page list was + stale), re-lists the wiki and falls back to an update. """ if dry_run: click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content))) @@ -144,16 +147,36 @@ def sync_page( return "updated" # Create new page via POST /wiki/new - client._request( - "POST", - "/wiki/new", - json={ - "title": page_title, - "content_base64": content_b64, - "message": f"Sync from docs/ — create {page_title}", - }, - ) - return "created" + try: + client._request( + "POST", + "/wiki/new", + json={ + "title": page_title, + "content_base64": content_b64, + "message": f"Sync from docs/ — create {page_title}", + }, + ) + return "created" + except APIError as e: + if e.status == 400 and "already exists" in e.message.lower(): + # The page list was stale (e.g. after a timeout-retry returned + # incomplete data). Re-list and fall back to update. + click.echo(_(" Page '{title}' already exists (stale list). Re-listing and updating...", title=page_title)) + fresh_pages = _list_wiki_pages_with_retry(client) + if page_title in fresh_pages: + sub_url = fresh_pages[page_title] + client._request( + "PATCH", + f"/wiki/page/{sub_url}", + json={ + "title": page_title, + "content_base64": content_b64, + "message": f"Sync from docs/ — update {page_title} (create→update fallback)", + }, + ) + return "updated" + raise def verify_wiki_page( @@ -173,15 +196,15 @@ def verify_wiki_page( def _list_wiki_pages_with_retry(client: GiteaClient) -> dict[str, str]: """List wiki pages with tenacity retry on APIError. - The Gitea API can be briefly unavailable right after a batch of wiki - page updates. Uses the same tenacity pattern as ``api_clients`` for - exponential backoff. + The Gitea wiki API can be slow (it renders pages on each request) + and may time out. Uses 5 attempts with exponential backoff to handle + transient slowness. """ _logger = logging.getLogger("sync_wiki") @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=2, min=2, max=8), + stop=stop_after_attempt(5), + wait=wait_exponential(multiplier=2, min=2, max=16), retry=retry_if_exception_type(APIError), before_sleep=before_sleep_log(_logger, logging.WARNING), reraise=True, @@ -291,10 +314,14 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None: click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping))) try: - existing_pages = list_wiki_pages(client) + existing_pages = _list_wiki_pages_with_retry(client) except APIError as e: raise click.ClickException( - _("Failed to list existing wiki pages: {error}. Aborting to avoid creating duplicate pages.", error=e) + _( + "Failed to list existing wiki pages after retries: {error}. " + "Aborting to avoid creating duplicate pages.", + error=e, + ) ) from e if existing_pages: click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages))) diff --git a/src/devx/translations.json b/src/devx/translations.json index 8f6d7a6..39fab56 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -3343,12 +3343,20 @@ "ru": "Директория для сканирования (по умолчанию: tests/integration). Можно повторять.", "zh": "要扫描的目录(默认:tests/integration)。可重复。" }, - "Failed to list existing wiki pages: {error}. Aborting to avoid creating duplicate pages.": { - "bg": "Неуспешно извличане на съществуващи wiki страници: {error}. Прекратяване, за да се избегне създаване на дублирани страници.", - "de": "Abrufen bestehender Wiki-Seiten fehlgeschlagen: {error}. Abbruch, um doppelte Seiten zu vermeiden.", - "en": "Failed to list existing wiki pages: {error}. Aborting to avoid creating duplicate pages.", - "pl": "Nie udało się wylistować istniejących stron wiki: {error}. Przerywanie, aby uniknąć tworzenia zduplikowanych stron.", - "ru": "Не удалось получить список существующих wiki-страниц: {error}. Прерывание, чтобы избежать создания дубликатов страниц.", - "zh": "列出现有 wiki 页面失败:{error}。正在中止以避免创建重复页面。" + "Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.": { + "bg": "Неуспешно извличане на съществуващи wiki страници след повторни опити: {error}. Прекратяване, за да се избегне създаване на дублирани страници.", + "de": "Abrufen bestehender Wiki-Seiten nach Wiederholungen fehlgeschlagen: {error}. Abbruch, um doppelte Seiten zu vermeiden.", + "en": "Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.", + "pl": "Nie udało się wylistować istniejących stron wiki po ponownych próbach: {error}. Przerywanie, aby uniknąć tworzenia zduplikowanych stron.", + "ru": "Не удалось получить список существующих wiki-страниц после повторных попыток: {error}. Прерывание, чтобы избежать создания дубликатов страниц.", + "zh": "重试后列出现有 wiki 页面失败:{error}。正在中止以避免创建重复页面。" + }, + " Page '{title}' already exists (stale list). Re-listing and updating...": { + "bg": " Страницата '{title}' вече съществува (остарял списък). Пресписване и обновяване...", + "de": " Seite '{title}' existiert bereits (veraltete Liste). Neu auflisten und aktualisieren...", + "en": " Page '{title}' already exists (stale list). Re-listing and updating...", + "pl": " Strona '{title}' już istnieje (nieaktualna lista). Ponowne listowanie i aktualizacja...", + "ru": " Страница '{title}' уже существует (устаревший список). Повторное получение списка и обновление...", + "zh": " 页面 '{title}' 已存在(列表过期)。重新列出并更新..." } } diff --git a/tests/unit/test_sync_wiki.py b/tests/unit/test_sync_wiki.py index 8bc060d..d21dfb5 100644 --- a/tests/unit/test_sync_wiki.py +++ b/tests/unit/test_sync_wiki.py @@ -171,6 +171,35 @@ class TestSyncPage: assert "content" not in payload assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated" + def test_create_falls_back_to_update_on_already_exists(self) -> None: + """When create fails with 400 'already exists', re-list and update.""" + client = MagicMock() + # First call: POST /wiki/new → 400 already exists + # Second call: PATCH /wiki/page/{sub_url} → success + create_error = APIError(400, "wiki page already exists [title: Test-Page]") + client._request.side_effect = [create_error, MagicMock()] + with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", return_value={"Test-Page": "Test-Page.-"}): + result = sync_page(client, "Test-Page", "# Content", {}, dry_run=False) + assert result == "updated" + # Verify PATCH was called (second call) + patch_call = client._request.call_args_list[1] + assert patch_call.args[0] == "PATCH" + assert "/wiki/page/Test-Page.-" in patch_call.args[1] + + def test_create_raises_non_400_error(self) -> None: + """Non-400 errors from create should propagate, not trigger fallback.""" + client = MagicMock() + client._request.side_effect = APIError(500, "server error") + with pytest.raises(APIError): + sync_page(client, "Test-Page", "# Content", {}, dry_run=False) + + def test_create_raises_400_not_already_exists(self) -> None: + """400 errors that don't mention 'already exists' should propagate.""" + client = MagicMock() + client._request.side_effect = APIError(400, "invalid title") + with pytest.raises(APIError): + sync_page(client, "Test-Page", "# Content", {}, dry_run=False) + class TestVerifyWikiPage: def test_verifies_matching_content(self) -> None: @@ -538,14 +567,14 @@ class TestMain: @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.ci.sync_wiki.GiteaClient") def test_initial_list_api_error_aborts(self, mock_client_cls: MagicMock) -> None: - """When the initial page list fails, sync aborts to avoid duplicate pages.""" + """When the initial page list fails after retries, sync aborts to avoid duplicate pages.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): - with patch("devx.ci.sync_wiki.list_wiki_pages", side_effect=APIError(0, "timeout")): + with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")): with patch("devx.ci.sync_wiki.sync_page", return_value="created"): runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo"]) @@ -559,17 +588,15 @@ class TestMain: """When --verify re-fetch fails after retries, verification is skipped gracefully.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client + # Initial list succeeds, but verify re-fetch fails + list_side_effect = [{"Home": "Home"}, APIError(0, "timeout")] with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"): - with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): + with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=list_side_effect): with patch("devx.ci.sync_wiki.sync_page", return_value="updated"): - with patch( - "devx.ci.sync_wiki._list_wiki_pages_with_retry", - side_effect=APIError(0, "timeout"), - ): - runner = CliRunner() - result = runner.invoke(main, ["--repo", "owner/repo", "--verify"]) + runner = CliRunner() + result = runner.invoke(main, ["--repo", "owner/repo", "--verify"]) assert result.exit_code == 0 assert "Skipping content verification" in result.output