Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587d3a6ca4 | ||
|
|
9d75e408ae | ||
|
|
412bbea01d |
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.27.3] - 2026-06-30
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Retry wiki integrity check on transient API timeout
|
||||||
|
|
||||||
## [0.27.2] - 2026-06-29
|
## [0.27.2] - 2026-06-29
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.27.2"
|
__version__ = "0.27.3"
|
||||||
|
|||||||
@@ -21,11 +21,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
from tenacity import (
|
||||||
|
before_sleep_log,
|
||||||
|
retry,
|
||||||
|
retry_if_exception_type,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential,
|
||||||
|
)
|
||||||
|
|
||||||
from devx.api_clients import GiteaClient
|
from devx.api_clients import GiteaClient
|
||||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
@@ -86,11 +94,12 @@ def decode_content(content_b64: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||||
"""List existing wiki pages, returning {title: sub_url}."""
|
"""List existing wiki pages, returning {title: sub_url}.
|
||||||
try:
|
|
||||||
pages = client._request("GET", "/wiki/pages").json()
|
Raises :class:`APIError` if the wiki API is unavailable — the caller
|
||||||
except APIError:
|
is responsible for retrying or handling the failure.
|
||||||
return {}
|
"""
|
||||||
|
pages = client._request("GET", "/wiki/pages").json()
|
||||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +170,28 @@ def verify_wiki_page(
|
|||||||
return actual.strip() == expected_content.strip()
|
return actual.strip() == expected_content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
_logger = logging.getLogger("sync_wiki")
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=2, min=2, max=8),
|
||||||
|
retry=retry_if_exception_type(APIError),
|
||||||
|
before_sleep=before_sleep_log(_logger, logging.WARNING),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
def _do_list() -> dict[str, str]:
|
||||||
|
return list_wiki_pages(client)
|
||||||
|
|
||||||
|
return _do_list()
|
||||||
|
|
||||||
|
|
||||||
def verify_wiki_integrity(
|
def verify_wiki_integrity(
|
||||||
client: GiteaClient,
|
client: GiteaClient,
|
||||||
mapping: dict[str, str],
|
mapping: dict[str, str],
|
||||||
@@ -176,9 +207,25 @@ def verify_wiki_integrity(
|
|||||||
5. Page count matches
|
5. Page count matches
|
||||||
|
|
||||||
Returns a list of failure messages (empty if all checks pass).
|
Returns a list of failure messages (empty if all checks pass).
|
||||||
|
If the wiki API is temporarily unavailable (all retry attempts
|
||||||
|
fail), returns an empty list with a warning — the sync itself
|
||||||
|
already succeeded, so a transient API outage should not fail the job.
|
||||||
"""
|
"""
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
existing_pages = list_wiki_pages(client)
|
|
||||||
|
try:
|
||||||
|
existing_pages = _list_wiki_pages_with_retry(client)
|
||||||
|
except APIError:
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"WARNING: Could not fetch wiki page list after retries. "
|
||||||
|
"The sync itself succeeded ({count} pages updated), but the "
|
||||||
|
"integrity check could not verify them due to a transient API issue.",
|
||||||
|
count=len(synced),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
expected_titles = set(mapping.values())
|
expected_titles = set(mapping.values())
|
||||||
|
|
||||||
# Check 1: Page count
|
# Check 1: Page count
|
||||||
@@ -243,7 +290,10 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
|||||||
|
|
||||||
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
||||||
|
|
||||||
existing_pages = list_wiki_pages(client)
|
try:
|
||||||
|
existing_pages = list_wiki_pages(client)
|
||||||
|
except APIError:
|
||||||
|
existing_pages = {}
|
||||||
if existing_pages:
|
if existing_pages:
|
||||||
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
||||||
|
|
||||||
@@ -302,7 +352,16 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
|||||||
else:
|
else:
|
||||||
click.echo(_("\nVerifying wiki pages have content..."))
|
click.echo(_("\nVerifying wiki pages have content..."))
|
||||||
# Re-fetch the page list to get updated sub_urls
|
# Re-fetch the page list to get updated sub_urls
|
||||||
existing_pages = list_wiki_pages(client)
|
try:
|
||||||
|
existing_pages = _list_wiki_pages_with_retry(client)
|
||||||
|
except APIError:
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"WARNING: Could not re-fetch wiki page list for verification. "
|
||||||
|
"Skipping content verification due to transient API issue."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
failures = 0
|
failures = 0
|
||||||
for page_title, expected_content in sorted(synced.items()):
|
for page_title, expected_content in sorted(synced.items()):
|
||||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||||
|
|||||||
@@ -2527,6 +2527,22 @@
|
|||||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||||
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
||||||
},
|
},
|
||||||
|
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
|
||||||
|
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||||
|
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||||
|
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||||
|
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
|
||||||
|
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||||
|
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
|
||||||
|
},
|
||||||
|
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
|
||||||
|
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||||
|
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||||
|
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||||
|
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
|
||||||
|
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||||
|
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
|
||||||
|
},
|
||||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||||
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from devx.ci.sync_wiki import (
|
|||||||
verify_wiki_integrity,
|
verify_wiki_integrity,
|
||||||
verify_wiki_page,
|
verify_wiki_page,
|
||||||
)
|
)
|
||||||
|
from devx.exceptions import APIError
|
||||||
|
|
||||||
|
|
||||||
class TestEncodeContent:
|
class TestEncodeContent:
|
||||||
@@ -97,13 +98,11 @@ class TestReadDocContent:
|
|||||||
|
|
||||||
|
|
||||||
class TestListWikiPages:
|
class TestListWikiPages:
|
||||||
def test_returns_empty_on_api_error(self) -> None:
|
def test_raises_on_api_error(self) -> None:
|
||||||
from devx.exceptions import APIError
|
|
||||||
|
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
client._request.side_effect = APIError(404, "not found")
|
client._request.side_effect = APIError(404, "not found")
|
||||||
result = list_wiki_pages(client)
|
with pytest.raises(APIError):
|
||||||
assert result == {}
|
list_wiki_pages(client)
|
||||||
|
|
||||||
def test_returns_page_dict(self) -> None:
|
def test_returns_page_dict(self) -> None:
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
@@ -284,6 +283,43 @@ class TestVerifyWikiIntegrity:
|
|||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
failures = verify_wiki_integrity(client, mapping, synced)
|
||||||
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
|
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
|
||||||
|
|
||||||
|
def test_transient_api_failure_returns_empty(self) -> None:
|
||||||
|
"""When the wiki API is unavailable after retries, integrity check
|
||||||
|
should return no failures (sync already succeeded)."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
# _list_wiki_pages_with_retry raises APIError (retries exhausted)
|
||||||
|
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
|
||||||
|
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
||||||
|
synced = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||||
|
failures = verify_wiki_integrity(client, mapping, synced)
|
||||||
|
assert failures == []
|
||||||
|
|
||||||
|
def test_transient_api_failure_recovers_on_retry(self) -> None:
|
||||||
|
"""When the wiki API recovers after a retry, integrity check proceeds normally."""
|
||||||
|
client = MagicMock()
|
||||||
|
pages = {"Home": "Home", "FAQ": "FAQ"}
|
||||||
|
contents = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||||
|
|
||||||
|
def mock_request(method, path, **kwargs):
|
||||||
|
resp = MagicMock()
|
||||||
|
if path == "/wiki/pages":
|
||||||
|
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
|
||||||
|
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
|
||||||
|
|
||||||
|
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
||||||
|
synced = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||||
|
failures = verify_wiki_integrity(client, mapping, synced)
|
||||||
|
assert failures == []
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
class TestMain:
|
||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||||
@@ -498,3 +534,41 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Integrity check" not in result.output
|
assert "Integrity check" not in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||||
|
def test_initial_list_api_error_treated_as_empty(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""When the initial page list fails, sync proceeds treating wiki as empty."""
|
||||||
|
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.sync_page", return_value="created"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Created: Home" in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||||
|
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
|
||||||
|
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", return_value={"Home": "Home"}):
|
||||||
|
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"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Skipping content verification" in result.output
|
||||||
|
|||||||
Reference in New Issue
Block a user