feat: export task archives on demand (#14)
This commit is contained in:
@@ -141,6 +141,7 @@ REQUIRED_FILES = (
|
||||
"wiki-docs.json",
|
||||
"dev_scripts/wiki_docs.py",
|
||||
"dev_scripts/sync_wiki_docs.py",
|
||||
"dev_scripts/export_task_archives.py",
|
||||
".gitea/issue_template/epic.md",
|
||||
".gitea/issue_template/mvp.md",
|
||||
".gitea/issue_template/task.md",
|
||||
@@ -176,6 +177,15 @@ def check_archives(errors: list[str]) -> None:
|
||||
if not re.match(r"^\d+-.+\.md$", path.name):
|
||||
errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
metadata, _ = parse_mirror(content)
|
||||
except WikiDocsError as exc:
|
||||
errors.append(f"{path.name} 的任务镜像无效:{exc}")
|
||||
continue
|
||||
if re.fullmatch(r"Task-\d+-.+", metadata.get("wiki_page", "")) is None:
|
||||
errors.append(f"{path.name} 的 wiki_page 不是任务归档页面")
|
||||
if re.fullmatch(r"[0-9a-f]{40,64}", metadata.get("wiki_revision", "")) is None:
|
||||
errors.append(f"{path.name} 的 wiki_revision 无效")
|
||||
for heading in ARCHIVE_HEADINGS:
|
||||
if heading not in content:
|
||||
errors.append(f"{path.name} 缺少章节:{heading}")
|
||||
@@ -252,7 +262,7 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
|
||||
"单元任务是唯一正式实施单位",
|
||||
"高风险修改必须停止",
|
||||
"用户没有明确验收通过前不得关闭",
|
||||
"长期文档必须先修改 Wiki",
|
||||
"长期核心文档必须先修改 Wiki",
|
||||
"提交只包含当前工单相关文件",
|
||||
"### 自然语言快捷指令",
|
||||
"`只分析`",
|
||||
@@ -262,6 +272,8 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
|
||||
"`继续工单 #N`",
|
||||
"`检查工单 #N`",
|
||||
"`同步文档`",
|
||||
"`导出任务归档`",
|
||||
"`导出全部任务归档`",
|
||||
"`#N 验收通过`",
|
||||
"### 需求记录与流转",
|
||||
"不得臆造用户原话",
|
||||
@@ -309,7 +321,7 @@ def core_mapping_errors(configured_mappings: dict[str, str]) -> list[str]:
|
||||
|
||||
|
||||
def check_wiki_mirrors(errors: list[str]) -> None:
|
||||
"""检查每份本地文档都有显式映射和可追踪的镜像头。"""
|
||||
"""检查核心映射与镜像头;任务快照由 check_archives 单独检查。"""
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
@@ -323,6 +335,7 @@ def check_wiki_mirrors(errors: list[str]) -> None:
|
||||
mapped_paths = {mapping.path for mapping in config.mappings}
|
||||
actual_paths = {
|
||||
path.relative_to(ROOT).as_posix() for path in (ROOT / "docs").rglob("*.md")
|
||||
if path.parent != ROOT / "docs" / "task"
|
||||
}
|
||||
for path in sorted(actual_paths - mapped_paths):
|
||||
errors.append(f"docs 中存在未登记的 Wiki 镜像:{path}")
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""把 Gitea Wiki 任务归档人工按需导出到 docs/task。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from new_task_archive import safe_title
|
||||
from wiki_docs import (
|
||||
DEFAULT_CONFIG,
|
||||
ROOT,
|
||||
WikiClient,
|
||||
WikiDocsError,
|
||||
dirty_paths,
|
||||
load_config,
|
||||
parse_mirror,
|
||||
write_mirror,
|
||||
)
|
||||
|
||||
|
||||
TASK_PAGE_PATTERN = re.compile(r"^Task-(?P<number>\d+)-(?P<title>.+)$")
|
||||
|
||||
|
||||
def task_revision(metadata: dict[str, Any], page_name: str) -> str:
|
||||
last_commit = metadata.get("last_commit")
|
||||
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
|
||||
if not isinstance(revision, str) or not revision:
|
||||
raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}")
|
||||
return revision
|
||||
|
||||
|
||||
def existing_task_mirrors(root: Path = ROOT) -> dict[str, Path]:
|
||||
"""按镜像头匹配已有文件,兼容历史自定义文件名。"""
|
||||
|
||||
mirrors: dict[str, Path] = {}
|
||||
task_dir = root / "docs" / "task"
|
||||
if not task_dir.is_dir():
|
||||
return mirrors
|
||||
for path in task_dir.glob("*.md"):
|
||||
try:
|
||||
metadata, _ = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError) as exc:
|
||||
raise WikiDocsError(f"已有任务镜像无效 {path.name}:{exc}") from exc
|
||||
page_name = metadata.get("wiki_page", "")
|
||||
if not TASK_PAGE_PATTERN.fullmatch(page_name):
|
||||
raise WikiDocsError(f"已有任务镜像页面名无效 {path.name}:{page_name}")
|
||||
if page_name in mirrors:
|
||||
raise WikiDocsError(f"任务页面存在重复本地镜像:{page_name}")
|
||||
mirrors[page_name] = path
|
||||
return mirrors
|
||||
|
||||
|
||||
def task_target(page_name: str, root: Path = ROOT) -> Path:
|
||||
match = TASK_PAGE_PATTERN.fullmatch(page_name)
|
||||
if match is None:
|
||||
raise WikiDocsError(f"不是任务归档页面:{page_name}")
|
||||
title = safe_title(match.group("title"))
|
||||
if not title:
|
||||
raise WikiDocsError(f"任务归档标题无效:{page_name}")
|
||||
return root / "docs" / "task" / f"{match.group('number')}-{title}.md"
|
||||
|
||||
|
||||
def export_task_archives(
|
||||
client: WikiClient, *, export_all: bool = False, root: Path = ROOT
|
||||
) -> list[str]:
|
||||
"""增量或全量读取任务归档;绝不删除本地文件。"""
|
||||
|
||||
dirty = dirty_paths(["docs/task"], root)
|
||||
if dirty:
|
||||
raise WikiDocsError(
|
||||
"本地任务镜像存在未提交改动,已停止以防覆盖:\n" + "\n".join(dirty)
|
||||
)
|
||||
|
||||
existing = existing_task_mirrors(root)
|
||||
pages = []
|
||||
for metadata in client.list_pages():
|
||||
title = metadata.get("title")
|
||||
if isinstance(title, str) and TASK_PAGE_PATTERN.fullmatch(title):
|
||||
pages.append((int(title.split("-", 2)[1]), title, metadata))
|
||||
pages.sort(key=lambda item: (item[0], item[1]))
|
||||
|
||||
messages: list[str] = []
|
||||
targets: set[Path] = set()
|
||||
for _, page_name, metadata in pages:
|
||||
target = existing.get(page_name, task_target(page_name, root))
|
||||
if target in targets:
|
||||
raise WikiDocsError(f"多个任务页面映射到同一本地路径:{target.name}")
|
||||
targets.add(target)
|
||||
revision = task_revision(metadata, page_name)
|
||||
if not export_all and target.is_file():
|
||||
local_metadata, _ = parse_mirror(target.read_text(encoding="utf-8"))
|
||||
if (
|
||||
local_metadata.get("wiki_page") == page_name
|
||||
and local_metadata.get("wiki_revision") == revision
|
||||
):
|
||||
messages.append(f"跳过:{target.relative_to(root)} <- {page_name}@{revision[:12]}")
|
||||
continue
|
||||
page = client.get_page_from_metadata(metadata, page_name)
|
||||
changed = write_mirror(target, page)
|
||||
action = "已导出" if changed else "无变化"
|
||||
messages.append(f"{action}:{target.relative_to(root)} <- {page_name}@{revision[:12]}")
|
||||
return messages
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="人工按需导出 Gitea Wiki 任务归档")
|
||||
parser.add_argument(
|
||||
"--all", action="store_true", help="全量读取全部线上任务归档;默认按 revision 增量"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
config = load_config(Path(args.config).resolve())
|
||||
messages = export_task_archives(
|
||||
WikiClient(config), export_all=args.all
|
||||
)
|
||||
except WikiDocsError as exc:
|
||||
print(f"错误:{exc}")
|
||||
return 1
|
||||
for message in messages:
|
||||
print(message)
|
||||
print("任务归档全量导出完成" if args.all else "任务归档增量导出完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +1,4 @@
|
||||
"""先在 Gitea Wiki 创建任务归档,再登记并导出本地镜像。"""
|
||||
"""只在 Gitea Wiki 创建任务归档;本地镜像由人工按需导出。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,12 +9,9 @@ from pathlib import Path
|
||||
|
||||
from wiki_docs import (
|
||||
DEFAULT_CONFIG,
|
||||
Mapping,
|
||||
WikiClient,
|
||||
WikiDocsError,
|
||||
append_mapping,
|
||||
load_config,
|
||||
sync_all,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +40,7 @@ def build_archive(
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="在 Gitea Wiki 创建任务归档并导出 docs/task 镜像"
|
||||
description="在 Gitea Wiki 创建任务归档,不自动导出本地镜像"
|
||||
)
|
||||
parser.add_argument("issue_number", help="Gitea 工单号,例如 123")
|
||||
parser.add_argument("title", help="简短任务标题")
|
||||
@@ -61,15 +58,9 @@ def main() -> int:
|
||||
try:
|
||||
config = load_config(Path(args.config).resolve())
|
||||
page_name = f"Task-{args.issue_number}-{short_title}"
|
||||
local_path = f"docs/task/{args.issue_number}-{short_title}.md"
|
||||
mapping = Mapping(page=page_name, path=local_path)
|
||||
if any(
|
||||
item.page == mapping.page or item.path == mapping.path
|
||||
for item in config.mappings
|
||||
):
|
||||
raise WikiDocsError(f"任务归档已经登记:{page_name}")
|
||||
|
||||
client = WikiClient(config)
|
||||
if any(item.get("title") == page_name for item in client.list_pages()):
|
||||
raise WikiDocsError(f"任务归档已经存在:{page_name}")
|
||||
template = client.get_page("Task-Archive-Template").text
|
||||
issue_url = (
|
||||
f"{config.gitea_url}/{config.owner}/{config.repository}/issues/"
|
||||
@@ -83,17 +74,12 @@ def main() -> int:
|
||||
content,
|
||||
f"docs: 创建任务 #{args.issue_number} 归档草稿",
|
||||
)
|
||||
append_mapping(config, mapping)
|
||||
updated_config = load_config(config.path)
|
||||
messages = sync_all(updated_config, client)
|
||||
except WikiDocsError as exc:
|
||||
print(f"错误:{exc}")
|
||||
return 1
|
||||
|
||||
print(f"已创建 Wiki:{page.html_url}")
|
||||
for message in messages:
|
||||
print(message)
|
||||
print(f"已登记镜像:{local_path}")
|
||||
print("未导出本地任务归档;需要时运行 export_task_archives.py")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""从 Gitea Wiki 单向导出本地 docs 镜像。"""
|
||||
"""从 Gitea Wiki 单向导出配置中的核心 docs 镜像。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,7 @@ from wiki_docs import DEFAULT_CONFIG, WikiClient, WikiDocsError, load_config, sy
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步 docs 镜像")
|
||||
parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步核心 docs 镜像")
|
||||
parser.add_argument(
|
||||
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
|
||||
)
|
||||
|
||||
@@ -210,6 +210,14 @@ class WikiClient:
|
||||
raise WikiDocsError(
|
||||
f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像"
|
||||
)
|
||||
return self.get_page_from_metadata(metadata, page_name)
|
||||
|
||||
def get_page_from_metadata(
|
||||
self, metadata: dict[str, Any], page_name: str | None = None
|
||||
) -> WikiPage:
|
||||
"""使用页面列表元数据读取正文,避免重复获取完整页面列表。"""
|
||||
|
||||
resolved_name = page_name or _required_string(metadata, "title")
|
||||
sub_url = _required_string(metadata, "sub_url")
|
||||
page = self._request(
|
||||
"GET",
|
||||
@@ -218,20 +226,22 @@ class WikiClient:
|
||||
f"{quote(sub_url, safe='%')}",
|
||||
)
|
||||
if not isinstance(page, dict):
|
||||
raise WikiDocsError(f"Wiki 页面响应格式无效:{page_name}")
|
||||
raise WikiDocsError(f"Wiki 页面响应格式无效:{resolved_name}")
|
||||
encoded_content = page.get("content_base64")
|
||||
if not isinstance(encoded_content, str):
|
||||
raise WikiDocsError(f"Wiki 页面没有 content_base64:{page_name}")
|
||||
raise WikiDocsError(f"Wiki 页面没有 content_base64:{resolved_name}")
|
||||
try:
|
||||
text = base64.b64decode(encoded_content, validate=True).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise WikiDocsError(f"Wiki 页面不是有效的 UTF-8 Markdown:{page_name}") from exc
|
||||
raise WikiDocsError(
|
||||
f"Wiki 页面不是有效的 UTF-8 Markdown:{resolved_name}"
|
||||
) from exc
|
||||
last_commit = page.get("last_commit")
|
||||
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
|
||||
if not isinstance(revision, str) or not revision:
|
||||
raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}")
|
||||
raise WikiDocsError(f"Wiki 页面缺少 revision:{resolved_name}")
|
||||
title = page.get("title")
|
||||
resolved_title = title if isinstance(title, str) and title else page_name
|
||||
resolved_title = title if isinstance(title, str) and title else resolved_name
|
||||
html_url = (
|
||||
f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/"
|
||||
f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}"
|
||||
@@ -303,7 +313,14 @@ def render_mirror(page: WikiPage, existing: str | None = None) -> str:
|
||||
|
||||
|
||||
def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]:
|
||||
paths = [mapping.path for mapping in config.mappings]
|
||||
return dirty_paths([mapping.path for mapping in config.mappings], root)
|
||||
|
||||
|
||||
def dirty_paths(paths: list[str], root: Path = ROOT) -> list[str]:
|
||||
"""返回指定路径中已有、修改或未跟踪的工作区条目。"""
|
||||
|
||||
if not paths:
|
||||
return []
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain", "--", *paths],
|
||||
cwd=root,
|
||||
@@ -329,6 +346,17 @@ def _write_atomic(path: Path, content: str) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def write_mirror(path: Path, page: WikiPage) -> bool:
|
||||
"""写入一份 Wiki 镜像;内容无变化时返回 False。"""
|
||||
|
||||
existing = path.read_text(encoding="utf-8") if path.is_file() else None
|
||||
rendered = render_mirror(page, existing)
|
||||
if existing == rendered:
|
||||
return False
|
||||
_write_atomic(path, rendered)
|
||||
return True
|
||||
|
||||
|
||||
def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
|
||||
if not path.is_file():
|
||||
return [f"缺少镜像:{mapping.path}"]
|
||||
|
||||
Reference in New Issue
Block a user