refactor: 合并 dev_scripts 入口为单一 harness.py (#23)

原有 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 <noreply@anthropic.com>
This commit is contained in:
ila
2026-08-18 21:36:15 +08:00
co-authored by Claude Opus 5
parent 65ead42f36
commit bfc0726c33
8 changed files with 295 additions and 296 deletions
+13 -13
View File
@@ -13,13 +13,13 @@
| 用途 | 命令 | | 用途 | 命令 |
|---|---| |---|---|
| 查看工作区 | `git status --short --branch` | | 查看工作区 | `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` | | 运行单元测试 | `python -m unittest discover -s tests -v` |
| 导出核心 Wiki 镜像 | `python dev_scripts/sync_wiki_docs.py` | | 导出核心 Wiki 镜像 | `python dev_scripts/harness.py sync` |
| 检查核心 Wiki 镜像 | `python dev_scripts/sync_wiki_docs.py --check` | | 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` |
| 创建任务归档 | `python dev_scripts/new_task_archive.py 123 "修复登录超时"` | | 创建任务归档 | `python dev_scripts/harness.py archive 123 "修复登录超时"` |
| 增量导出任务归档 | `python dev_scripts/export_task_archives.py` | | 增量导出任务归档 | `python dev_scripts/harness.py export` |
| 全量导出任务归档 | `python dev_scripts/export_task_archives.py --all` | | 全量导出任务归档 | `python dev_scripts/harness.py export --all` |
## 1. 永久规则 ## 1. 永久规则
@@ -55,7 +55,7 @@
6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。 6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。
7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。 7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。
8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。 8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。
9. 长期核心文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;任务归档默认只更新 Wiki,不自动导出本地。不得直接编辑镜像后反向覆盖 Wiki。 9. 长期核心文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/harness.py sync` 导出本地镜像;任务归档默认只更新 Wiki,不自动导出本地。不得直接编辑镜像后反向覆盖 Wiki。
Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。 Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。
@@ -65,7 +65,7 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
- 优先使用项目已配置的 Gitea MCP 查询和写入 Wiki;MCP 不可用或不支持所需写操作时,才使用 Gitea API,并在初始化工单记录回退原因。凭据只从环境或 MCP 安全配置读取。 - 优先使用项目已配置的 Gitea MCP 查询和写入 Wiki;MCP 不可用或不支持所需写操作时,才使用 Gitea API,并在初始化工单记录回退原因。凭据只从环境或 MCP 安全配置读取。
- 先查询线上页面列表;`Home` 不存在时必须先创建 `Home`,在线回读正文并记录 revision,然后再逐页创建或更新其他核心映射页面。 - 先查询线上页面列表;`Home` 不存在时必须先创建 `Home`,在线回读正文并记录 revision,然后再逐页创建或更新其他核心映射页面。
- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 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`:核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查。
- `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。 - `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。
- `同步文档`:读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档;不修改 Wiki、不自动提交。 - `同步文档`:读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档;不修改 Wiki、不自动提交。
- `导出任务归档`:人工触发 `python dev_scripts/export_task_archives.py`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。 - `导出任务归档`:人工触发 `python dev_scripts/harness.py export`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。
- `导出全部任务归档`:人工触发 `python dev_scripts/export_task_archives.py --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。 - `导出全部任务归档`:人工触发 `python dev_scripts/harness.py export --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。
- `#N 验收通过`:仅在用户明确验收后,更新 Wiki 归档、同步必要的核心文档、推送、同步父工单并关闭任务;不自动导出任务归档。 - `#N 验收通过`:仅在用户明确验收后,更新 Wiki 归档、同步必要的核心文档、推送、同步父工单并关闭任务;不自动导出任务归档。
方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。任务归档导出必须由用户明确提出,其他指令不得隐式执行。Gitea 工单不导出全文,`docs/task/` 只是可能不完整的 Wiki 任务归档快照。详细语义见 [开发工作流](docs/01-workflow.md)。 方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。任务归档导出必须由用户明确提出,其他指令不得隐式执行。Gitea 工单不导出全文,`docs/task/` 只是可能不完整的 Wiki 任务归档快照。详细语义见 [开发工作流](docs/01-workflow.md)。
@@ -167,8 +167,8 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。 1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。
2. 工单保持“待验收”,用户没有明确验收通过前不得关闭。 2. 工单保持“待验收”,用户没有明确验收通过前不得关闭。
3. 运行 `python dev_scripts/new_task_archive.py <编号> "<短标题>"`,只创建 Wiki 任务归档,不登记或导出本地镜像。 3. 运行 `python dev_scripts/harness.py archive <编号> "<短标题>"`,只创建 Wiki 任务归档,不登记或导出本地镜像。
4. 读取确认 Wiki,运行 `python dev_scripts/sync_wiki_docs.py --check` 检查核心镜像,并把任务归档页面、revision 和提交哈希写回工单。只有用户明确提出时才增量或全量导出任务归档。 4. 读取确认 Wiki,运行 `python dev_scripts/harness.py sync --check` 检查核心镜像,并把任务归档页面、revision 和提交哈希写回工单。只有用户明确提出时才增量或全量导出任务归档。
5. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。 5. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。
MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。 MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。
@@ -195,7 +195,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。 - 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。
- 启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界、日志位置发生变化时,必须更新对应 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. 引导提交例外 ## 9. 引导提交例外
+3 -7
View File
@@ -29,9 +29,7 @@ DevHarness 是一个以 Gitea 工单管理任务过程、以 Gitea Wiki 管理
7. 开始产品代码前运行: 7. 开始产品代码前运行:
```powershell ```powershell
python dev_scripts/sync_wiki_docs.py python dev_scripts/harness.py sync --verify
python dev_scripts/check_harness.py --strict
python dev_scripts/sync_wiki_docs.py --check
``` ```
新仓库在 Gitea 尚未建立前允许一次不关联工单的引导提交。远端和工单系统配置完成后,所有改变程序行为的工作都必须先有单元任务工单。 新仓库在 Gitea 尚未建立前允许一次不关联工单的引导提交。远端和工单系统配置完成后,所有改变程序行为的工作都必须先有单元任务工单。
@@ -48,10 +46,8 @@ docs/02-07*.md 代码地图、业务、验证、修改、排
docs/templates/task-archive.md Wiki 任务归档模板的只读镜像 docs/templates/task-archive.md Wiki 任务归档模板的只读镜像
docs/task/ 人工按需导出的 Wiki 任务归档快照 docs/task/ 人工按需导出的 Wiki 任务归档快照
wiki-docs.json 核心 Wiki 页面到本地镜像的显式映射 wiki-docs.json 核心 Wiki 页面到本地镜像的显式映射
dev_scripts/check_harness.py 模板和归档的最小自检 dev_scripts/harness.py check / sync / archive / export 单一入口
dev_scripts/sync_wiki_docs.py 单向导出或检查 Wiki 镜像 dev_scripts/wiki_docs.py Gitea Wiki 客户端与镜像生成库
dev_scripts/new_task_archive.py 只创建 Wiki 任务归档
dev_scripts/export_task_archives.py 人工增量或全量导出任务归档
``` ```
## 设计原则 ## 设计原则
-131
View File
@@ -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<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,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 from __future__ import annotations
import argparse import argparse
import re import re
from datetime import date
from pathlib import Path 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] ROOT = Path(__file__).resolve().parents[1]
CORE_PAGE_PATHS = { CORE_PAGE_PATHS = {
"Home": "docs/README.md", "Home": "docs/README.md",
@@ -170,8 +193,7 @@ REQUIRED_FILES = (
*CORE_DOCUMENT_REQUIREMENTS, *CORE_DOCUMENT_REQUIREMENTS,
"wiki-docs.json", "wiki-docs.json",
"dev_scripts/wiki_docs.py", "dev_scripts/wiki_docs.py",
"dev_scripts/sync_wiki_docs.py", "dev_scripts/harness.py",
"dev_scripts/export_task_archives.py",
".gitea/issue_template/epic.md", ".gitea/issue_template/epic.md",
".gitea/issue_template/mvp.md", ".gitea/issue_template/mvp.md",
".gitea/issue_template/task.md", ".gitea/issue_template/task.md",
@@ -344,7 +366,7 @@ def check_repository_readme(errors: list[str], root: Path = ROOT) -> None:
"优先使用已配置的 Gitea MCP", "优先使用已配置的 Gitea MCP",
"`Home` 不存在时先创建并回读 `Home`", "`Home` 不存在时先创建并回读 `Home`",
"本地 `docs/` 的存在不能证明线上 Wiki 已初始化", "本地 `docs/` 的存在不能证明线上 Wiki 已初始化",
"python dev_scripts/sync_wiki_docs.py --check", "python dev_scripts/harness.py sync --verify",
) )
for section in missing_sections(content, required): for section in missing_sections(content, required):
errors.append(f"README.md 缺少:{section}") 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") 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] = [] errors: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
check_required_files(errors) check_required_files(errors)
@@ -465,5 +593,132 @@ def main() -> int:
return 0 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__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
-87
View File
@@ -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())
-33
View File
@@ -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())
+1 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "dev_scripts")) sys.path.insert(0, str(ROOT / "dev_scripts"))
from check_harness import ( # noqa: E402 from harness import ( # noqa: E402
CORE_DOCUMENT_REQUIREMENTS, CORE_DOCUMENT_REQUIREMENTS,
CORE_PAGE_PATHS, CORE_PAGE_PATHS,
REQUIRED_FILES, REQUIRED_FILES,
+10 -11
View File
@@ -12,14 +12,12 @@ from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "dev_scripts")) sys.path.insert(0, str(ROOT / "dev_scripts"))
from new_task_archive import ( # noqa: E402 from harness import ( # noqa: E402
build_archive, build_archive,
main as new_archive_main,
safe_title,
)
from export_task_archives import ( # noqa: E402
existing_task_mirrors, existing_task_mirrors,
export_task_archives, export_task_archives,
main as harness_main,
safe_title,
task_target, task_target,
) )
from wiki_docs import ( # noqa: E402 from wiki_docs import ( # noqa: E402
@@ -167,7 +165,7 @@ class ArchiveTests(unittest.TestCase):
self.assertIn("Task-12-login", result) self.assertIn("Task-12-login", result)
self.assertNotIn("YYYY-MM-DD", 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: def test_create_archive_does_not_change_core_mapping(self, client_class) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
config_path = Path(directory) / "wiki-docs.json" config_path = Path(directory) / "wiki-docs.json"
@@ -203,14 +201,15 @@ class ArchiveTests(unittest.TestCase):
sys, sys,
"argv", "argv",
[ [
"new_task_archive.py", "harness.py",
"archive",
"14", "14",
"按需导出", "按需导出",
"--config", "--config",
str(config_path), str(config_path),
], ],
): ):
result = new_archive_main() result = harness_main()
self.assertEqual(config_path.read_text(encoding="utf-8"), original) self.assertEqual(config_path.read_text(encoding="utf-8"), original)
self.assertEqual(result, 0) self.assertEqual(result, 0)
client.create_page.assert_called_once() client.create_page.assert_called_once()
@@ -239,7 +238,7 @@ class ArchiveTests(unittest.TestCase):
"2-初级维护者文档体系.md", "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: def test_incremental_export_skips_same_revision(self, _dirty) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@@ -266,7 +265,7 @@ class ArchiveTests(unittest.TestCase):
client.get_page_from_metadata.assert_not_called() client.get_page_from_metadata.assert_not_called()
@patch( @patch(
"export_task_archives.dirty_paths", "harness.dirty_paths",
return_value=[" M docs/task/14-按需导出.md"], return_value=[" M docs/task/14-按需导出.md"],
) )
def test_export_stops_before_wiki_read_when_task_mirror_is_dirty( def test_export_stops_before_wiki_read_when_task_mirror_is_dirty(
@@ -277,7 +276,7 @@ class ArchiveTests(unittest.TestCase):
export_task_archives(client) export_task_archives(client)
client.list_pages.assert_not_called() 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: def test_full_export_reads_all_and_never_deletes_extra_file(self, _dirty) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)