chore(workflow): adopt lightweight governance (#221)
This commit is contained in:
+137
-14
@@ -8,6 +8,7 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
@@ -26,6 +27,7 @@ HEADER_PATTERN = re.compile(
|
||||
rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\Z",
|
||||
re.DOTALL,
|
||||
)
|
||||
RATE_LIMIT_RETRY_DELAYS = (1.0, 2.0, 4.0)
|
||||
|
||||
|
||||
class WikiDocsError(RuntimeError):
|
||||
@@ -142,11 +144,33 @@ class WikiClient:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
body = response.read()
|
||||
except HTTPError as exc:
|
||||
if self.token and method == "GET" and exc.code in {401, 403, 404}:
|
||||
request_error: HTTPError | URLError | None = None
|
||||
body = b""
|
||||
for attempt in range(len(RATE_LIMIT_RETRY_DELAYS) + 1):
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
body = response.read()
|
||||
request_error = None
|
||||
break
|
||||
except HTTPError as exc:
|
||||
if (
|
||||
method == "GET"
|
||||
and exc.code == 429
|
||||
and attempt < len(RATE_LIMIT_RETRY_DELAYS)
|
||||
):
|
||||
exc.close()
|
||||
time.sleep(RATE_LIMIT_RETRY_DELAYS[attempt])
|
||||
continue
|
||||
request_error = exc
|
||||
break
|
||||
except URLError as exc:
|
||||
request_error = exc
|
||||
break
|
||||
else: # pragma: no cover - for 循环必定通过成功或异常分支退出
|
||||
raise WikiDocsError(f"Gitea API {method} {api_path} 请求失败")
|
||||
|
||||
if isinstance(request_error, HTTPError):
|
||||
if self.token and method == "GET" and request_error.code in {401, 403, 404}:
|
||||
# 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于
|
||||
# 另一个实例或已失效。只对只读请求安全降级为匿名访问。
|
||||
anonymous_headers = {"Accept": "application/json"}
|
||||
@@ -167,12 +191,13 @@ class WikiClient:
|
||||
f"无法连接 Gitea:{anonymous_exc.reason}"
|
||||
) from anonymous_exc
|
||||
else:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
detail = request_error.read().decode("utf-8", errors="replace")
|
||||
raise WikiDocsError(
|
||||
f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}"
|
||||
) from exc
|
||||
except URLError as exc:
|
||||
raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc
|
||||
f"Gitea API {method} {api_path} 返回 "
|
||||
f"{request_error.code}: {detail}"
|
||||
) from request_error
|
||||
elif isinstance(request_error, URLError):
|
||||
raise WikiDocsError(f"无法连接 Gitea:{request_error.reason}") from request_error
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
@@ -381,8 +406,73 @@ def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]:
|
||||
"""检查或写入所有显式映射;绝不处理映射外的文件。"""
|
||||
def _metadata_revision(metadata: dict[str, Any]) -> str | None:
|
||||
last_commit = metadata.get("last_commit")
|
||||
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
|
||||
return revision if isinstance(revision, str) and revision else None
|
||||
|
||||
|
||||
def _find_page_metadata(
|
||||
pages: list[dict[str, Any]], page_name: str
|
||||
) -> dict[str, Any]:
|
||||
metadata = next(
|
||||
(
|
||||
item
|
||||
for item in pages
|
||||
if item.get("title") == page_name or item.get("sub_url") == page_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if metadata is None:
|
||||
raise WikiDocsError(
|
||||
f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像"
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def _metadata_identity(
|
||||
config: Config, metadata: dict[str, Any], fallback_title: str
|
||||
) -> tuple[str, str] | None:
|
||||
title = metadata.get("title")
|
||||
sub_url = metadata.get("sub_url")
|
||||
if not isinstance(title, str) or not title:
|
||||
title = fallback_title
|
||||
if not isinstance(sub_url, str) or not sub_url:
|
||||
return None
|
||||
url = (
|
||||
f"{config.gitea_url}/{quote(config.owner, safe='')}/"
|
||||
f"{quote(config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}"
|
||||
)
|
||||
return title, url
|
||||
|
||||
|
||||
def _local_revision(
|
||||
path: Path, *, expected_title: str, expected_url: str
|
||||
) -> str | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
metadata, _body = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError):
|
||||
return None
|
||||
if (
|
||||
metadata.get("wiki_page") != expected_title
|
||||
or metadata.get("wiki_url") != expected_url
|
||||
or not metadata.get("synchronized_at")
|
||||
):
|
||||
return None
|
||||
revision = metadata.get("wiki_revision")
|
||||
return revision if revision else None
|
||||
|
||||
|
||||
def sync_all(
|
||||
config: Config,
|
||||
client: WikiClient,
|
||||
*,
|
||||
check: bool = False,
|
||||
deep_check: bool = False,
|
||||
) -> list[str]:
|
||||
"""检查或写入所有显式映射;写入前先完成全部远端读取。"""
|
||||
|
||||
if not check:
|
||||
dirty = dirty_mirror_paths(config)
|
||||
@@ -392,10 +482,43 @@ def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list
|
||||
"已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details
|
||||
)
|
||||
|
||||
messages: list[str] = []
|
||||
pages = client.list_pages()
|
||||
resolved: list[tuple[Mapping, Path, WikiPage | None, str | None]] = []
|
||||
for mapping in config.mappings:
|
||||
page = client.get_page(mapping.page)
|
||||
metadata = _find_page_metadata(pages, mapping.page)
|
||||
target = ROOT / PurePosixPath(mapping.path)
|
||||
remote_revision = _metadata_revision(metadata)
|
||||
identity = _metadata_identity(config, metadata, mapping.page)
|
||||
local_revision = (
|
||||
_local_revision(
|
||||
target, expected_title=identity[0], expected_url=identity[1]
|
||||
)
|
||||
if identity is not None
|
||||
else None
|
||||
)
|
||||
if not deep_check and remote_revision and local_revision == remote_revision:
|
||||
action = "一致" if check else "无变化"
|
||||
resolved.append(
|
||||
(
|
||||
mapping,
|
||||
target,
|
||||
None,
|
||||
f"{action}:{mapping.path} <- "
|
||||
f"{mapping.page}@{remote_revision[:12]}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
page = client.get_page_from_metadata(metadata, mapping.page)
|
||||
resolved.append((mapping, target, page, None))
|
||||
|
||||
messages: list[str] = []
|
||||
for mapping, target, page, skip_message in resolved:
|
||||
if skip_message is not None:
|
||||
messages.append(skip_message)
|
||||
continue
|
||||
if page is None: # pragma: no cover - resolved 元组由上面的单一路径构造
|
||||
raise WikiDocsError(f"Wiki 页面未解析:{mapping.page}")
|
||||
if check:
|
||||
errors = check_mirror(mapping, page, target)
|
||||
if errors:
|
||||
|
||||
Reference in New Issue
Block a user