From bfc0726c330dc6b2e87ceb79e1d1fd7cea848b1a Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 18 Aug 2026 21:36:15 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=90=88=E5=B9=B6=20dev=5Fscripts?= =?UTF-8?q?=20=E5=85=A5=E5=8F=A3=E4=B8=BA=E5=8D=95=E4=B8=80=20harness.py?= =?UTF-8?q?=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原有 4 个命令行入口(check_harness、sync_wiki_docs、new_task_archive、 export_task_archives)需要在多处文档分别描述,同步核心镜像还要按顺序跑 3 条命令。 合并为 dev_scripts/harness.py,提供 check / sync / archive / export 四个子命令, 各命令实现逻辑逐字取自原脚本,行为未改变;新增 sync --verify 一次完成 导出 → check --strict → 一致性校验。wiki_docs.py 作为库保持不变。 破坏性变更:4 个旧入口已删除,不保留兼容转发。已采用 DevHarness 的业务项目 升级基线时必须同步修改自己的命令与文档。 Co-Authored-By: Claude Opus 5 --- AGENTS.md | 26 +- README.md | 10 +- dev_scripts/export_task_archives.py | 131 --------- dev_scripts/{check_harness.py => harness.py} | 281 ++++++++++++++++++- dev_scripts/new_task_archive.py | 87 ------ dev_scripts/sync_wiki_docs.py | 33 --- tests/test_harness_docs.py | 2 +- tests/test_wiki_docs.py | 21 +- 8 files changed, 295 insertions(+), 296 deletions(-) delete mode 100644 dev_scripts/export_task_archives.py rename dev_scripts/{check_harness.py => harness.py} (63%) delete mode 100644 dev_scripts/new_task_archive.py delete mode 100644 dev_scripts/sync_wiki_docs.py diff --git a/AGENTS.md b/AGENTS.md index 9e82f4f..74bf3e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,13 +13,13 @@ | 用途 | 命令 | |---|---| | 查看工作区 | `git status --short --branch` | -| 检查模板结构 | `python dev_scripts/check_harness.py --strict` | +| 检查模板结构 | `python dev_scripts/harness.py check --strict` | | 运行单元测试 | `python -m unittest discover -s tests -v` | -| 导出核心 Wiki 镜像 | `python dev_scripts/sync_wiki_docs.py` | -| 检查核心 Wiki 镜像 | `python dev_scripts/sync_wiki_docs.py --check` | -| 创建任务归档 | `python dev_scripts/new_task_archive.py 123 "修复登录超时"` | -| 增量导出任务归档 | `python dev_scripts/export_task_archives.py` | -| 全量导出任务归档 | `python dev_scripts/export_task_archives.py --all` | +| 导出核心 Wiki 镜像 | `python dev_scripts/harness.py sync` | +| 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` | +| 创建任务归档 | `python dev_scripts/harness.py archive 123 "修复登录超时"` | +| 增量导出任务归档 | `python dev_scripts/harness.py export` | +| 全量导出任务归档 | `python dev_scripts/harness.py export --all` | ## 1. 永久规则 @@ -55,7 +55,7 @@ 6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。 7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。 8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。 -9. 长期核心文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;任务归档默认只更新 Wiki,不自动导出本地。不得直接编辑镜像后反向覆盖 Wiki。 +9. 长期核心文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/harness.py sync` 导出本地镜像;任务归档默认只更新 Wiki,不自动导出本地。不得直接编辑镜像后反向覆盖 Wiki。 Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。 @@ -65,7 +65,7 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明 - 优先使用项目已配置的 Gitea MCP 查询和写入 Wiki;MCP 不可用或不支持所需写操作时,才使用 Gitea API,并在初始化工单记录回退原因。凭据只从环境或 MCP 安全配置读取。 - 先查询线上页面列表;`Home` 不存在时必须先创建 `Home`,在线回读正文并记录 revision,然后再逐页创建或更新其他核心映射页面。 - 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化,不得开始产品代码。 -- 产品编码前必须运行 `python dev_scripts/sync_wiki_docs.py`、`python dev_scripts/check_harness.py --strict` 和 `python dev_scripts/sync_wiki_docs.py --check`;全部成功才表示线上 Wiki 和核心镜像初始化完成。 +- 产品编码前必须运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示线上 Wiki 和核心镜像初始化完成。 ### 工单与设计证据双门禁 @@ -91,8 +91,8 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明 - `继续工单 #N`:核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查。 - `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。 - `同步文档`:读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档;不修改 Wiki、不自动提交。 -- `导出任务归档`:人工触发 `python dev_scripts/export_task_archives.py`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。 -- `导出全部任务归档`:人工触发 `python dev_scripts/export_task_archives.py --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。 +- `导出任务归档`:人工触发 `python dev_scripts/harness.py export`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。 +- `导出全部任务归档`:人工触发 `python dev_scripts/harness.py export --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。 - `#N 验收通过`:仅在用户明确验收后,更新 Wiki 归档、同步必要的核心文档、推送、同步父工单并关闭任务;不自动导出任务归档。 方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。任务归档导出必须由用户明确提出,其他指令不得隐式执行。Gitea 工单不导出全文,`docs/task/` 只是可能不完整的 Wiki 任务归档快照。详细语义见 [开发工作流](docs/01-workflow.md)。 @@ -167,8 +167,8 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明 1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。 2. 工单保持“待验收”,用户没有明确验收通过前不得关闭。 -3. 运行 `python dev_scripts/new_task_archive.py <编号> "<短标题>"`,只创建 Wiki 任务归档,不登记或导出本地镜像。 -4. 读取确认 Wiki,运行 `python dev_scripts/sync_wiki_docs.py --check` 检查核心镜像,并把任务归档页面、revision 和提交哈希写回工单。只有用户明确提出时才增量或全量导出任务归档。 +3. 运行 `python dev_scripts/harness.py archive <编号> "<短标题>"`,只创建 Wiki 任务归档,不登记或导出本地镜像。 +4. 读取确认 Wiki,运行 `python dev_scripts/harness.py sync --check` 检查核心镜像,并把任务归档页面、revision 和提交哈希写回工单。只有用户明确提出时才增量或全量导出任务归档。 5. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。 MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。 @@ -195,7 +195,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。 - 启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界、日志位置发生变化时,必须更新对应 Wiki。 - 普通内部重构只有在入口、行为、配置和验证方式均未改变时,才可以记录为不影响长期文档。 -- 必需核心页面及结构以 `python dev_scripts/check_harness.py --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与任务归档的分工见 [开发工作流](docs/01-workflow.md)。 +- 必需核心页面及结构以 `python dev_scripts/harness.py check --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与任务归档的分工见 [开发工作流](docs/01-workflow.md)。 ## 9. 引导提交例外 diff --git a/README.md b/README.md index c04f8da..e2ab1cb 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,7 @@ DevHarness 是一个以 Gitea 工单管理任务过程、以 Gitea Wiki 管理 7. 开始产品代码前运行: ```powershell - python dev_scripts/sync_wiki_docs.py - python dev_scripts/check_harness.py --strict - python dev_scripts/sync_wiki_docs.py --check + python dev_scripts/harness.py sync --verify ``` 新仓库在 Gitea 尚未建立前允许一次不关联工单的引导提交。远端和工单系统配置完成后,所有改变程序行为的工作都必须先有单元任务工单。 @@ -48,10 +46,8 @@ docs/02-07*.md 代码地图、业务、验证、修改、排 docs/templates/task-archive.md Wiki 任务归档模板的只读镜像 docs/task/ 人工按需导出的 Wiki 任务归档快照 wiki-docs.json 核心 Wiki 页面到本地镜像的显式映射 -dev_scripts/check_harness.py 模板和归档的最小自检 -dev_scripts/sync_wiki_docs.py 单向导出或检查 Wiki 镜像 -dev_scripts/new_task_archive.py 只创建 Wiki 任务归档 -dev_scripts/export_task_archives.py 人工增量或全量导出任务归档 +dev_scripts/harness.py check / sync / archive / export 单一入口 +dev_scripts/wiki_docs.py Gitea Wiki 客户端与镜像生成库 ``` ## 设计原则 diff --git a/dev_scripts/export_task_archives.py b/dev_scripts/export_task_archives.py deleted file mode 100644 index 8923af0..0000000 --- a/dev_scripts/export_task_archives.py +++ /dev/null @@ -1,131 +0,0 @@ -"""把 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\d+)-(?P.+)$") - - -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()) diff --git a/dev_scripts/check_harness.py b/dev_scripts/harness.py similarity index 63% rename from dev_scripts/check_harness.py rename to dev_scripts/harness.py index 3016173..eed971c 100644 --- a/dev_scripts/check_harness.py +++ b/dev_scripts/harness.py @@ -1,14 +1,37 @@ -"""检查 DevHarness 必需文件、核心文档和任务归档的基本结构。""" +"""DevHarness 单一命令行入口。 + +子命令: + check 检查必需文件、核心文档和任务归档结构 + sync 从 Gitea Wiki 单向导出或校验核心 docs 镜像 + archive 在 Gitea Wiki 创建任务归档 + export 人工按需把 Wiki 任务归档导出到 docs/task + +各子命令的实现逻辑取自原来的 check_harness.py、sync_wiki_docs.py、 +new_task_archive.py 和 export_task_archives.py,行为未改变。 +""" from __future__ import annotations import argparse import re +from datetime import date from pathlib import Path +from typing import Any -from wiki_docs import WikiDocsError, load_config, parse_mirror +from wiki_docs import ( + DEFAULT_CONFIG, + WikiClient, + WikiDocsError, + dirty_paths, + load_config, + parse_mirror, + sync_all, + write_mirror, +) +# ---------------------------------------------------------------- 结构检查 + ROOT = Path(__file__).resolve().parents[1] CORE_PAGE_PATHS = { "Home": "docs/README.md", @@ -170,8 +193,7 @@ REQUIRED_FILES = ( *CORE_DOCUMENT_REQUIREMENTS, "wiki-docs.json", "dev_scripts/wiki_docs.py", - "dev_scripts/sync_wiki_docs.py", - "dev_scripts/export_task_archives.py", + "dev_scripts/harness.py", ".gitea/issue_template/epic.md", ".gitea/issue_template/mvp.md", ".gitea/issue_template/task.md", @@ -344,7 +366,7 @@ def check_repository_readme(errors: list[str], root: Path = ROOT) -> None: "优先使用已配置的 Gitea MCP", "`Home` 不存在时先创建并回读 `Home`", "本地 `docs/` 的存在不能证明线上 Wiki 已初始化", - "python dev_scripts/sync_wiki_docs.py --check", + "python dev_scripts/harness.py sync --verify", ) for section in missing_sections(content, required): errors.append(f"README.md 缺少:{section}") @@ -432,15 +454,121 @@ def check_wiki_mirrors(errors: list[str]) -> None: errors.append(f"{mapping.path} 缺少 synchronized_at") -def main() -> int: - parser = argparse.ArgumentParser(description="检查 DevHarness 项目结构") - parser.add_argument( - "--strict", - action="store_true", - help="项目档案有占位内容时返回失败", - ) - args = parser.parse_args() +# ---------------------------------------------------------------- 任务归档 +def safe_title(title: str) -> str: + """把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。""" + + cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip()) + cleaned = re.sub(r"\s+", "-", cleaned) + cleaned = re.sub(r"-+", "-", cleaned) + return cleaned.strip(".-") + + +def build_archive( + template: str, + issue_number: str, + title: str, + page_name: str, + issue_url: str, +) -> str: + content = template.replace("<工单号>", issue_number, 1) + content = content.replace("<标题>", title.strip(), 1) + content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1) + content = content.replace("<链接>", issue_url, 1) + return content.replace("<页面名>", page_name, 1) + + +# ---------------------------------------------------------------- 归档导出 + +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 run_check(args: argparse.Namespace) -> int: errors: list[str] = [] warnings: list[str] = [] check_required_files(errors) @@ -465,5 +593,132 @@ def main() -> int: return 0 +def run_sync(args: argparse.Namespace) -> int: + """--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。""" + + if args.verify: + steps = ( + ("同步", lambda: run_sync( + argparse.Namespace(check=False, verify=False, config=args.config))), + ("结构检查", lambda: run_check(argparse.Namespace(strict=True))), + ("一致性校验", lambda: run_sync( + argparse.Namespace(check=True, verify=False, config=args.config))), + ) + for name, step in steps: + code = step() + if code != 0: + print(f"错误:{name}未通过,已停止") + return code + return 0 + + try: + config = load_config(Path(args.config).resolve()) + messages = sync_all(config, WikiClient(config), check=args.check) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + for message in messages: + print(message) + print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成") + return 0 + + +def run_archive(args: argparse.Namespace) -> int: + short_title = safe_title(args.title) + if not args.issue_number.isdigit(): + print("错误:工单号必须是数字") + return 1 + if not short_title: + print("错误:标题不能为空") + return 1 + + try: + config = load_config(Path(args.config).resolve()) + page_name = f"Task-{args.issue_number}-{short_title}" + 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/" + f"{args.issue_number}" + ) + content = build_archive( + template, args.issue_number, args.title, page_name, issue_url + ) + page = client.create_page( + page_name, + content, + f"docs: 创建任务 #{args.issue_number} 归档草稿", + ) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + + print(f"已创建 Wiki:{page.html_url}") + print("未导出本地任务归档;需要时运行 harness.py export") + return 0 + + +def run_export(args: argparse.Namespace) -> int: + 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 + + +def main() -> int: + parser = argparse.ArgumentParser(description="DevHarness 检查、同步与归档工具") + sub = parser.add_subparsers(dest="command", required=True) + + p_check = sub.add_parser("check", help="检查 DevHarness 项目结构") + p_check.add_argument( + "--strict", action="store_true", help="项目档案有占位内容时返回失败" + ) + p_check.set_defaults(func=run_check) + + p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像") + p_sync.add_argument( + "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" + ) + p_sync.add_argument( + "--verify", + action="store_true", + help="依次执行导出、check --strict 和一致性校验", + ) + p_sync.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" + ) + p_sync.set_defaults(func=run_sync) + + p_archive = sub.add_parser("archive", help="在 Gitea Wiki 创建任务归档") + p_archive.add_argument("issue_number", help="Gitea 工单号,例如 123") + p_archive.add_argument("title", help="简短任务标题") + p_archive.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置" + ) + p_archive.set_defaults(func=run_archive) + + p_export = sub.add_parser("export", help="人工按需导出 Gitea Wiki 任务归档") + p_export.add_argument( + "--all", + action="store_true", + help="全量读取全部线上任务归档;默认按 revision 增量", + ) + p_export.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置" + ) + p_export.set_defaults(func=run_export) + + args = parser.parse_args() + return args.func(args) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/dev_scripts/new_task_archive.py b/dev_scripts/new_task_archive.py deleted file mode 100644 index 25b88fe..0000000 --- a/dev_scripts/new_task_archive.py +++ /dev/null @@ -1,87 +0,0 @@ -"""只在 Gitea Wiki 创建任务归档;本地镜像由人工按需导出。""" - -from __future__ import annotations - -import argparse -import re -from datetime import date -from pathlib import Path - -from wiki_docs import ( - DEFAULT_CONFIG, - WikiClient, - WikiDocsError, - load_config, -) - - -def safe_title(title: str) -> str: - """把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。""" - - cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip()) - cleaned = re.sub(r"\s+", "-", cleaned) - cleaned = re.sub(r"-+", "-", cleaned) - return cleaned.strip(".-") - - -def build_archive( - template: str, - issue_number: str, - title: str, - page_name: str, - issue_url: str, -) -> str: - content = template.replace("<工单号>", issue_number, 1) - content = content.replace("<标题>", title.strip(), 1) - content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1) - content = content.replace("<链接>", issue_url, 1) - return content.replace("<页面名>", page_name, 1) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="在 Gitea Wiki 创建任务归档,不自动导出本地镜像" - ) - parser.add_argument("issue_number", help="Gitea 工单号,例如 123") - parser.add_argument("title", help="简短任务标题") - parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置") - args = parser.parse_args() - - short_title = safe_title(args.title) - if not args.issue_number.isdigit(): - print("错误:工单号必须是数字") - return 1 - if not short_title: - print("错误:标题不能为空") - return 1 - - try: - config = load_config(Path(args.config).resolve()) - page_name = f"Task-{args.issue_number}-{short_title}" - 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/" - f"{args.issue_number}" - ) - content = build_archive( - template, args.issue_number, args.title, page_name, issue_url - ) - page = client.create_page( - page_name, - content, - f"docs: 创建任务 #{args.issue_number} 归档草稿", - ) - except WikiDocsError as exc: - print(f"错误:{exc}") - return 1 - - print(f"已创建 Wiki:{page.html_url}") - print("未导出本地任务归档;需要时运行 export_task_archives.py") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/dev_scripts/sync_wiki_docs.py b/dev_scripts/sync_wiki_docs.py deleted file mode 100644 index 25a30cf..0000000 --- a/dev_scripts/sync_wiki_docs.py +++ /dev/null @@ -1,33 +0,0 @@ -"""从 Gitea Wiki 单向导出配置中的核心 docs 镜像。""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from wiki_docs import DEFAULT_CONFIG, WikiClient, WikiDocsError, load_config, sync_all - - -def main() -> int: - parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步核心 docs 镜像") - parser.add_argument( - "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" - ) - parser.add_argument( - "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" - ) - args = parser.parse_args() - try: - config = load_config(Path(args.config).resolve()) - messages = sync_all(config, WikiClient(config), check=args.check) - except WikiDocsError as exc: - print(f"错误:{exc}") - return 1 - for message in messages: - print(message) - print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_harness_docs.py b/tests/test_harness_docs.py index d50819a..ca5fd69 100644 --- a/tests/test_harness_docs.py +++ b/tests/test_harness_docs.py @@ -9,7 +9,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "dev_scripts")) -from check_harness import ( # noqa: E402 +from harness import ( # noqa: E402 CORE_DOCUMENT_REQUIREMENTS, CORE_PAGE_PATHS, REQUIRED_FILES, diff --git a/tests/test_wiki_docs.py b/tests/test_wiki_docs.py index c902096..bc6b58e 100644 --- a/tests/test_wiki_docs.py +++ b/tests/test_wiki_docs.py @@ -12,14 +12,12 @@ from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "dev_scripts")) -from new_task_archive import ( # noqa: E402 +from harness import ( # noqa: E402 build_archive, - main as new_archive_main, - safe_title, -) -from export_task_archives import ( # noqa: E402 existing_task_mirrors, export_task_archives, + main as harness_main, + safe_title, task_target, ) from wiki_docs import ( # noqa: E402 @@ -167,7 +165,7 @@ class ArchiveTests(unittest.TestCase): self.assertIn("Task-12-login", result) self.assertNotIn("YYYY-MM-DD", result) - @patch("new_task_archive.WikiClient") + @patch("harness.WikiClient") def test_create_archive_does_not_change_core_mapping(self, client_class) -> None: with tempfile.TemporaryDirectory() as directory: config_path = Path(directory) / "wiki-docs.json" @@ -203,14 +201,15 @@ class ArchiveTests(unittest.TestCase): sys, "argv", [ - "new_task_archive.py", + "harness.py", + "archive", "14", "按需导出", "--config", str(config_path), ], ): - result = new_archive_main() + result = harness_main() self.assertEqual(config_path.read_text(encoding="utf-8"), original) self.assertEqual(result, 0) client.create_page.assert_called_once() @@ -239,7 +238,7 @@ class ArchiveTests(unittest.TestCase): "2-初级维护者文档体系.md", ) - @patch("export_task_archives.dirty_paths", return_value=[]) + @patch("harness.dirty_paths", return_value=[]) def test_incremental_export_skips_same_revision(self, _dirty) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -266,7 +265,7 @@ class ArchiveTests(unittest.TestCase): client.get_page_from_metadata.assert_not_called() @patch( - "export_task_archives.dirty_paths", + "harness.dirty_paths", return_value=[" M docs/task/14-按需导出.md"], ) def test_export_stops_before_wiki_read_when_task_mirror_is_dirty( @@ -277,7 +276,7 @@ class ArchiveTests(unittest.TestCase): export_task_archives(client) client.list_pages.assert_not_called() - @patch("export_task_archives.dirty_paths", return_value=[]) + @patch("harness.dirty_paths", return_value=[]) def test_full_export_reads_all_and_never_deletes_extra_file(self, _dirty) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory)