feat: 接入 Wiki 镜像工具骨架 (#312)
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
"""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 (
|
||||
DEFAULT_CONFIG,
|
||||
MIRROR_START,
|
||||
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",
|
||||
"Project-Profile": "docs/00-project-profile.md",
|
||||
"Development-Workflow": "docs/01-workflow.md",
|
||||
"Architecture-and-Code-Map": "docs/02-architecture-and-code-map.md",
|
||||
"Business-Rules-and-Glossary": "docs/03-business-rules-and-glossary.md",
|
||||
"Local-Development-and-Verification": (
|
||||
"docs/04-local-development-and-verification.md"
|
||||
),
|
||||
"Common-Changes": "docs/05-common-changes.md",
|
||||
"Troubleshooting": "docs/06-troubleshooting.md",
|
||||
"Product-Requirements-Overview": (
|
||||
"docs/09-product-requirements-overview.md"
|
||||
),
|
||||
"Delivery-Documentation-Guide": "docs/delivery/README.md",
|
||||
"Audience-Document-Template": (
|
||||
"docs/delivery/audience-document-template.md"
|
||||
),
|
||||
"Task-Archive-Template": "docs/templates/task-archive.md",
|
||||
}
|
||||
CORE_DOCUMENT_REQUIREMENTS = {
|
||||
"docs/README.md": (
|
||||
"## 第一次阅读",
|
||||
"## 五分钟开始",
|
||||
"## 简单修改从哪里开始",
|
||||
"## 事实来源",
|
||||
),
|
||||
"docs/00-project-profile.md": (
|
||||
"## 基本信息",
|
||||
"## DevHarness 来源与基线",
|
||||
"## 子项目与交付单元",
|
||||
"## 技术栈与运行环境",
|
||||
"## 阅读入口",
|
||||
"## 常用命令",
|
||||
"## 环境、配置与凭据",
|
||||
),
|
||||
"docs/01-workflow.md": (
|
||||
"## Gitea 交互与工单最小读取",
|
||||
"## 新项目 Wiki 初始化门禁",
|
||||
"## 工单与设计证据双门禁",
|
||||
"### 先判断是否需要工单",
|
||||
"### 再判断设计证据",
|
||||
"### 线上原型审核与按需导出",
|
||||
"### 记录和重新确认",
|
||||
"## 面向初级维护者的修改边界",
|
||||
"## 每个任务的文档影响",
|
||||
"## 需求记录与流转",
|
||||
"## 稳定文档与可选历史快照",
|
||||
"## 自然语言快捷指令",
|
||||
"## 效率与范围控制",
|
||||
"### 严格控制范围",
|
||||
"### 渐进执行和修复",
|
||||
"### 复用已验证事实",
|
||||
"### 明确停止条件",
|
||||
),
|
||||
"docs/02-architecture-and-code-map.md": (
|
||||
"## 项目定位",
|
||||
"## 代码地图",
|
||||
"## 两条主要执行路径",
|
||||
"## 不可破坏的边界",
|
||||
),
|
||||
"docs/03-business-rules-and-glossary.md": (
|
||||
"## 核心术语",
|
||||
"## 工单状态",
|
||||
"## 稳定业务规则",
|
||||
"## 新项目需要补充什么",
|
||||
),
|
||||
"docs/04-local-development-and-verification.md": (
|
||||
"## 环境要求",
|
||||
"## 第一次运行",
|
||||
"## 常用调试方式",
|
||||
"## 完成修改前",
|
||||
),
|
||||
"docs/05-common-changes.md": (
|
||||
"## 风险分级",
|
||||
"## 修改 Wiki 文案",
|
||||
"## 调整 Harness 检查",
|
||||
"## 看懂 Agent 的修改",
|
||||
),
|
||||
"docs/06-troubleshooting.md": (
|
||||
"## 排查顺序",
|
||||
"## 必须停止的情况",
|
||||
),
|
||||
"docs/09-product-requirements-overview.md": (
|
||||
"## 本页用途",
|
||||
"## 事实来源边界",
|
||||
"## 当前需求索引",
|
||||
"## 登记规则",
|
||||
"## 原型与设计资产",
|
||||
"### 原型门禁",
|
||||
"### 线上原型与按需 HTML 快照",
|
||||
"### 原型确认记录",
|
||||
"## 状态规则",
|
||||
"## 更新时机",
|
||||
"## 最小验收清单",
|
||||
),
|
||||
"docs/delivery/README.md": (
|
||||
"## 什么时候需要交付文档",
|
||||
"## 受众与文档选择",
|
||||
"## 内部文档与交付文档边界",
|
||||
"## 编写和维护流程",
|
||||
"## 最小验收清单",
|
||||
),
|
||||
"docs/delivery/audience-document-template.md": (
|
||||
"## 文档信息",
|
||||
"## 目的与适用范围",
|
||||
"## 前置条件",
|
||||
"## 操作步骤",
|
||||
"## 常见错误与恢复",
|
||||
"## 安全与权限",
|
||||
"## 已知限制",
|
||||
"## 支持与升级处理",
|
||||
"## 版本记录",
|
||||
"## 交付前检查",
|
||||
),
|
||||
}
|
||||
REQUIRED_FILES = (
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"README.md",
|
||||
"docs/00-project-profile.md",
|
||||
"docs/01-workflow.md",
|
||||
"docs/templates/task-archive.md",
|
||||
*CORE_DOCUMENT_REQUIREMENTS,
|
||||
"wiki-docs.json",
|
||||
"dev_scripts/wiki_docs.py",
|
||||
"dev_scripts/harness.py",
|
||||
".gitea/issue_template/epic.md",
|
||||
".gitea/issue_template/mvp.md",
|
||||
".gitea/issue_template/task.md",
|
||||
)
|
||||
ARCHIVE_HEADINGS = (
|
||||
"## 背景与目标",
|
||||
"## 最终方案",
|
||||
"## 修改文件",
|
||||
"## 验收结果",
|
||||
"## 测试",
|
||||
"## 相关提交",
|
||||
)
|
||||
|
||||
|
||||
def check_required_files(errors: list[str]) -> None:
|
||||
for relative_path in REQUIRED_FILES:
|
||||
if not (ROOT / relative_path).is_file():
|
||||
errors.append(f"缺少必需文件:{relative_path}")
|
||||
|
||||
|
||||
def check_project_profile(errors: list[str], warnings: list[str], strict: bool) -> None:
|
||||
profile = ROOT / "docs" / "00-project-profile.md"
|
||||
if not profile.is_file():
|
||||
return
|
||||
if "<填写" in profile.read_text(encoding="utf-8"):
|
||||
message = "项目档案仍有未填写内容"
|
||||
(errors if strict else warnings).append(message)
|
||||
|
||||
|
||||
def check_archives(errors: list[str]) -> None:
|
||||
"""只校验新工作流导出的任务镜像,冻结的历史归档保持原样。
|
||||
|
||||
cmautobuy 在接入 Wiki 前已经积累了大量 `docs/task` 文件。它们没有
|
||||
Wiki 镜像头,也不应为了迁移而被批量重写。只有明确带镜像头的新文件
|
||||
才执行当前 DevHarness 的严格校验。
|
||||
"""
|
||||
|
||||
task_dir = ROOT / "docs" / "task"
|
||||
for path in task_dir.glob("*.md"):
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if not content.startswith(MIRROR_START):
|
||||
continue
|
||||
if not re.match(r"^\d+-.+\.md$", path.name):
|
||||
errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}")
|
||||
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}")
|
||||
if "**未验证部分**:" not in content:
|
||||
errors.append(f"{path.name} 没有记录未验证部分")
|
||||
|
||||
|
||||
def missing_sections(content: str, required: tuple[str, ...]) -> list[str]:
|
||||
return [section for section in required if section not in content]
|
||||
|
||||
|
||||
def check_core_documents(errors: list[str], root: Path = ROOT) -> None:
|
||||
"""检查初级维护者所需主题页的固定结构。"""
|
||||
|
||||
for relative_path, required in CORE_DOCUMENT_REQUIREMENTS.items():
|
||||
path = root / relative_path
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
_, body = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError):
|
||||
continue
|
||||
for section in missing_sections(body, required):
|
||||
errors.append(f"{relative_path} 缺少核心章节:{section}")
|
||||
|
||||
|
||||
def check_task_template(errors: list[str], root: Path = ROOT) -> None:
|
||||
path = root / ".gitea" / "issue_template" / "task.md"
|
||||
if not path.is_file():
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
required = (
|
||||
"## 依赖与并行",
|
||||
"- 前置工单:无 / #编号",
|
||||
"- 是否允许与前置工单并行:是 / 否",
|
||||
"- 原因:",
|
||||
"## 子项目影响",
|
||||
"- 仅影响的子项目 / 交付单元:",
|
||||
"- 是否跨子项目:是 / 否",
|
||||
"- 是否修改共享接口或契约:是 / 否;唯一事实来源:",
|
||||
"- 各子项目需要执行的验证:",
|
||||
"## 原始需求",
|
||||
"- 来源:用户对话 / Gitea / 其他",
|
||||
"- 提出时间:",
|
||||
"- 关键原话或脱敏摘要:",
|
||||
"## 需求变化记录",
|
||||
"| 日期 | 变化内容 | 原因 | 用户确认 |",
|
||||
"## 设计与原型门禁",
|
||||
"- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug",
|
||||
"- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据",
|
||||
"- 可编辑设计源、线上原型链接和访问检查:",
|
||||
"- 审核版本、revision、复制版本或确认日期及识别方式:",
|
||||
"- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求",
|
||||
"- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):",
|
||||
"- 状态:无 / 草稿 / 已确认 / 已废弃",
|
||||
"- 确认人、确认时间和覆盖范围:",
|
||||
"- 无需 UI 原型或无需任何原型的原因:",
|
||||
"## 文档影响",
|
||||
"- [ ] 不影响长期文档,原因:",
|
||||
"- [ ] 更新架构与代码地图",
|
||||
"- [ ] 更新业务规则与术语",
|
||||
"- [ ] 更新常见修改或故障排查",
|
||||
"## 交付文档影响",
|
||||
"- [ ] 无交付文档影响,原因:",
|
||||
"- [ ] 更新已有交付文档,受众与页面:",
|
||||
"- [ ] 新增交付文档,受众与页面:",
|
||||
"- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:",
|
||||
"## 任务记录与可选快照",
|
||||
"- 单次任务事实来源:当前 Gitea 工单正文与评论",
|
||||
"- [ ] 默认不创建任务快照",
|
||||
"- [ ] 用户明确要求专项快照;用途和范围:",
|
||||
"- [ ] 项目专用规则要求任务快照;规则入口:",
|
||||
)
|
||||
for section in missing_sections(content, required):
|
||||
errors.append(f"单元任务模板缺少:{section}")
|
||||
|
||||
|
||||
def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
|
||||
path = root / "AGENTS.md"
|
||||
if not path.is_file():
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
required = (
|
||||
"### 效率与范围控制",
|
||||
"#### 严格控制范围",
|
||||
"#### 渐进执行和修复",
|
||||
"#### 复用已验证事实",
|
||||
"#### 明确停止条件",
|
||||
"单元任务是唯一正式实施单位",
|
||||
"高风险修改必须停止",
|
||||
"用户没有明确验收通过前不得关闭",
|
||||
"只有长期事实变化时才修改 Wiki",
|
||||
"Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的事实来源",
|
||||
"默认不创建任务归档",
|
||||
"### Gitea 交互与工单最小读取",
|
||||
"查询、创建、更新、评论、状态变更及关闭操作",
|
||||
"优先关注当前状态、最新评论和首个未完成步骤",
|
||||
"连接器不支持评论分页或增量读取时允许读取完整工单",
|
||||
"不得为规避完整读取而新增本地工单、缓存或第二事实来源",
|
||||
"### 新项目 Wiki 初始化门禁",
|
||||
"`Home` 不存在时必须先创建 `Home`",
|
||||
"不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据",
|
||||
"提交只包含当前工单相关文件",
|
||||
"### 工单与设计证据双门禁",
|
||||
"新页面、独立用户功能、重大交互或导航变化",
|
||||
"`prototypes/<工单号>/<版本>/index.html`",
|
||||
"默认直接通过 Quant-UX 或其他设计工具的线上链接审核",
|
||||
"已确认的本地快照不得原位覆盖",
|
||||
"`导出原型 #N`",
|
||||
"`导出全部原型`",
|
||||
"代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案",
|
||||
"### 自然语言快捷指令",
|
||||
"`只分析`",
|
||||
"`建工单`",
|
||||
"`执行工单 #N`",
|
||||
"`建工单并做`",
|
||||
"`继续工单 #N`",
|
||||
"`检查工单 #N`",
|
||||
"`同步文档`",
|
||||
"`导出原型 #N`",
|
||||
"`导出全部原型`",
|
||||
"`导出任务归档`",
|
||||
"`导出全部任务归档`",
|
||||
"`#N 验收通过`",
|
||||
"### 需求记录与流转",
|
||||
"不得臆造用户原话",
|
||||
"不复制完整聊天",
|
||||
"Gitea 工单全文不导出到仓库",
|
||||
)
|
||||
for section in missing_sections(content, required):
|
||||
errors.append(f"AGENTS.md 缺少:{section}")
|
||||
|
||||
|
||||
def check_repository_readme(errors: list[str], root: Path = ROOT) -> None:
|
||||
"""检查快速开始包含线上 Wiki 初始化顺序和产品编码门禁。"""
|
||||
|
||||
path = root / "README.md"
|
||||
if not path.is_file():
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
required = (
|
||||
"创建 Gitea 远端仓库并推送当前引导提交,启用工单和 Wiki",
|
||||
"优先使用已配置的 Gitea MCP",
|
||||
"`Home` 不存在时先创建并回读 `Home`",
|
||||
"本地 `docs/` 的存在不能证明线上 Wiki 已初始化",
|
||||
"python dev_scripts/harness.py sync --verify",
|
||||
"Gitea 工单是单次任务唯一事实来源",
|
||||
"默认不创建任务归档",
|
||||
)
|
||||
for section in missing_sections(content, required):
|
||||
errors.append(f"README.md 缺少:{section}")
|
||||
|
||||
remote_index = content.find("创建 Gitea 远端仓库")
|
||||
wiki_index = content.find("`Home` 不存在时先创建")
|
||||
if remote_index < 0 or wiki_index < 0 or remote_index > wiki_index:
|
||||
errors.append("README.md 必须先创建 Gitea 远端,再创建 Wiki Home")
|
||||
|
||||
|
||||
def check_claude_code_entry(errors: list[str], root: Path = ROOT) -> None:
|
||||
"""检查 Claude Code 入口直接复用共同 Agent 规则。"""
|
||||
|
||||
path = root / "CLAUDE.md"
|
||||
if not path.is_file():
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
lines = {line.strip() for line in content.splitlines()}
|
||||
if "@AGENTS.md" not in lines:
|
||||
errors.append("CLAUDE.md 缺少独立的 @AGENTS.md 导入")
|
||||
required = (
|
||||
"共同规则事实来源",
|
||||
"只记录 Claude Code 特有",
|
||||
"只修改 `AGENTS.md`",
|
||||
"## 模型路由",
|
||||
"## Agent 交接",
|
||||
"## Haiku 只读约束",
|
||||
"当前模型足以完成任务时不升级模型",
|
||||
"Opus 输出方案后必须等待用户确认",
|
||||
"不让 Haiku 决定最终根因",
|
||||
"只读必须通过子 Agent 工具权限实现",
|
||||
)
|
||||
for section in missing_sections(content, required):
|
||||
errors.append(f"CLAUDE.md 缺少:{section}")
|
||||
|
||||
|
||||
def core_mapping_errors(configured_mappings: dict[str, str]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for page, expected_path in CORE_PAGE_PATHS.items():
|
||||
if configured_mappings.get(page) != expected_path:
|
||||
errors.append(
|
||||
f"核心 Wiki 页面映射缺失或路径错误:{page} -> {expected_path}"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def check_wiki_mirrors(errors: list[str]) -> None:
|
||||
"""检查核心映射与镜像头;任务快照由 check_archives 单独检查。"""
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
except WikiDocsError as exc:
|
||||
errors.append(str(exc))
|
||||
return
|
||||
|
||||
configured_mappings = {mapping.page: mapping.path for mapping in config.mappings}
|
||||
errors.extend(core_mapping_errors(configured_mappings))
|
||||
|
||||
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}")
|
||||
|
||||
for mapping in config.mappings:
|
||||
path = ROOT / mapping.path
|
||||
if not path.is_file():
|
||||
errors.append(f"缺少 Wiki 镜像:{mapping.path}")
|
||||
continue
|
||||
try:
|
||||
metadata, _ = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError) as exc:
|
||||
errors.append(f"Wiki 镜像无效 {mapping.path}:{exc}")
|
||||
continue
|
||||
if metadata.get("generated") != "true (请先修改 Gitea Wiki,禁止直接编辑本文件)":
|
||||
errors.append(f"{mapping.path} 没有只读镜像标记")
|
||||
if metadata.get("wiki_page") != mapping.page:
|
||||
errors.append(f"{mapping.path} 的 wiki_page 与映射不一致")
|
||||
revision = metadata.get("wiki_revision", "")
|
||||
if re.fullmatch(r"[0-9a-f]{40,64}", revision) is None:
|
||||
errors.append(f"{mapping.path} 的 wiki_revision 无效")
|
||||
if not metadata.get("synchronized_at"):
|
||||
errors.append(f"{mapping.path} 缺少 synchronized_at")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 任务归档
|
||||
|
||||
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"):
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if not content.startswith(MIRROR_START):
|
||||
continue
|
||||
try:
|
||||
metadata, _ = parse_mirror(content)
|
||||
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)
|
||||
check_project_profile(errors, warnings, args.strict)
|
||||
check_wiki_mirrors(errors)
|
||||
check_core_documents(errors)
|
||||
check_task_template(errors)
|
||||
check_agent_efficiency_rules(errors)
|
||||
check_repository_readme(errors)
|
||||
check_claude_code_entry(errors)
|
||||
check_archives(errors)
|
||||
|
||||
for warning in warnings:
|
||||
print(f"警告:{warning}")
|
||||
for error in errors:
|
||||
print(f"错误:{error}")
|
||||
|
||||
if errors:
|
||||
print(f"检查失败:{len(errors)} 个问题")
|
||||
return 1
|
||||
print("DevHarness 检查通过")
|
||||
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())
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Gitea Wiki 到本地 docs 镜像的共享实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_CONFIG = ROOT / "wiki-docs.json"
|
||||
MIRROR_START = "<!-- gitea-wiki-mirror:start -->"
|
||||
MIRROR_END = "<!-- gitea-wiki-mirror:end -->"
|
||||
HEADER_PATTERN = re.compile(
|
||||
rf"\A{re.escape(MIRROR_START)}\n(?P<metadata>.*?)\n"
|
||||
rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\Z",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
class WikiDocsError(RuntimeError):
|
||||
"""可供命令行直接展示的 Wiki 文档错误。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Mapping:
|
||||
page: str
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
path: Path
|
||||
gitea_url: str
|
||||
owner: str
|
||||
repository: str
|
||||
mappings: tuple[Mapping, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WikiPage:
|
||||
title: str
|
||||
sub_url: str
|
||||
text: str
|
||||
revision: str
|
||||
html_url: str
|
||||
|
||||
|
||||
def _required_string(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise WikiDocsError(f"配置字段 {key!r} 必须是非空字符串")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def validate_mappings(raw_mappings: Any) -> tuple[Mapping, ...]:
|
||||
"""校验显式页面映射,确保只会写入 docs 下的 Markdown。"""
|
||||
|
||||
if not isinstance(raw_mappings, list) or not raw_mappings:
|
||||
raise WikiDocsError("配置字段 'mappings' 必须是非空数组")
|
||||
|
||||
mappings: list[Mapping] = []
|
||||
pages: set[str] = set()
|
||||
paths: set[str] = set()
|
||||
for index, item in enumerate(raw_mappings, start=1):
|
||||
if not isinstance(item, dict):
|
||||
raise WikiDocsError(f"第 {index} 个映射必须是对象")
|
||||
page = _required_string(item, "page")
|
||||
path = _required_string(item, "path").replace("\\", "/")
|
||||
pure_path = PurePosixPath(path)
|
||||
if (
|
||||
pure_path.is_absolute()
|
||||
or ".." in pure_path.parts
|
||||
or not pure_path.parts
|
||||
or pure_path.parts[0] != "docs"
|
||||
or pure_path.suffix.lower() != ".md"
|
||||
):
|
||||
raise WikiDocsError(f"镜像路径必须是 docs/ 下的 Markdown:{path}")
|
||||
if page in pages:
|
||||
raise WikiDocsError(f"Wiki 页面重复映射:{page}")
|
||||
if path in paths:
|
||||
raise WikiDocsError(f"本地路径重复映射:{path}")
|
||||
pages.add(page)
|
||||
paths.add(path)
|
||||
mappings.append(Mapping(page=page, path=path))
|
||||
return tuple(mappings)
|
||||
|
||||
|
||||
def load_config(path: Path = DEFAULT_CONFIG) -> Config:
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise WikiDocsError(f"无法读取 Wiki 映射配置 {path}: {exc}") from exc
|
||||
if not isinstance(raw, dict) or raw.get("schema_version") != 1:
|
||||
raise WikiDocsError("wiki-docs.json 的 schema_version 必须为 1")
|
||||
configured_url = _required_string(raw, "gitea_url")
|
||||
gitea_url = os.environ.get("GITEA_URL", configured_url).rstrip("/")
|
||||
if gitea_url.endswith("/api/v1"):
|
||||
gitea_url = gitea_url[: -len("/api/v1")]
|
||||
return Config(
|
||||
path=path,
|
||||
gitea_url=gitea_url,
|
||||
owner=_required_string(raw, "owner"),
|
||||
repository=_required_string(raw, "repository"),
|
||||
mappings=validate_mappings(raw.get("mappings")),
|
||||
)
|
||||
|
||||
|
||||
class WikiClient:
|
||||
"""只使用标准库访问 Gitea Wiki API。"""
|
||||
|
||||
def __init__(self, config: Config, token: str | None = None) -> None:
|
||||
self.config = config
|
||||
self.token = token if token is not None else os.environ.get("GITEA_TOKEN")
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
api_path: str,
|
||||
*,
|
||||
payload: dict[str, Any] | None = None,
|
||||
query: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
url = f"{self.config.gitea_url}/api/v1{api_path}"
|
||||
if query:
|
||||
url = f"{url}?{urlencode(query)}"
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
data = None
|
||||
if payload is not None:
|
||||
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}:
|
||||
# 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于
|
||||
# 另一个实例或已失效。只对只读请求安全降级为匿名访问。
|
||||
anonymous_headers = {"Accept": "application/json"}
|
||||
anonymous_request = Request(
|
||||
url, data=data, headers=anonymous_headers, method=method
|
||||
)
|
||||
try:
|
||||
with urlopen(anonymous_request, timeout=30) as response:
|
||||
body = response.read()
|
||||
except HTTPError as anonymous_exc:
|
||||
detail = anonymous_exc.read().decode("utf-8", errors="replace")
|
||||
raise WikiDocsError(
|
||||
f"Gitea API {method} {api_path} 返回 "
|
||||
f"{anonymous_exc.code}: {detail}"
|
||||
) from anonymous_exc
|
||||
except URLError as anonymous_exc:
|
||||
raise WikiDocsError(
|
||||
f"无法连接 Gitea:{anonymous_exc.reason}"
|
||||
) from anonymous_exc
|
||||
else:
|
||||
detail = exc.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
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise WikiDocsError("Gitea API 返回了无效的 UTF-8 JSON") from exc
|
||||
|
||||
def list_pages(self) -> list[dict[str, Any]]:
|
||||
pages: list[dict[str, Any]] = []
|
||||
page_number = 1
|
||||
while True:
|
||||
batch = self._request(
|
||||
"GET",
|
||||
f"/repos/{quote(self.config.owner, safe='')}/"
|
||||
f"{quote(self.config.repository, safe='')}/wiki/pages",
|
||||
query={"page": page_number, "limit": 50},
|
||||
)
|
||||
if not isinstance(batch, list):
|
||||
raise WikiDocsError("Gitea Wiki 页面列表格式无效")
|
||||
pages.extend(item for item in batch if isinstance(item, dict))
|
||||
if len(batch) < 50:
|
||||
return pages
|
||||
page_number += 1
|
||||
|
||||
def get_page(self, page_name: str) -> WikiPage:
|
||||
metadata = next(
|
||||
(
|
||||
item
|
||||
for item in self.list_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 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",
|
||||
f"/repos/{quote(self.config.owner, safe='')}/"
|
||||
f"{quote(self.config.repository, safe='')}/wiki/page/"
|
||||
f"{quote(sub_url, safe='%')}",
|
||||
)
|
||||
if not isinstance(page, dict):
|
||||
raise WikiDocsError(f"Wiki 页面响应格式无效:{resolved_name}")
|
||||
encoded_content = page.get("content_base64")
|
||||
if not isinstance(encoded_content, str):
|
||||
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:{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:{resolved_name}")
|
||||
title = page.get("title")
|
||||
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='%')}"
|
||||
)
|
||||
return WikiPage(
|
||||
title=resolved_title,
|
||||
sub_url=sub_url,
|
||||
text=normalize_body(text),
|
||||
revision=revision,
|
||||
html_url=html_url,
|
||||
)
|
||||
|
||||
def create_page(self, title: str, content: str, message: str) -> WikiPage:
|
||||
if not self.token:
|
||||
raise WikiDocsError("创建 Wiki 页面需要通过 GITEA_TOKEN 提供写入令牌")
|
||||
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
self._request(
|
||||
"POST",
|
||||
f"/repos/{quote(self.config.owner, safe='')}/"
|
||||
f"{quote(self.config.repository, safe='')}/wiki/new",
|
||||
payload={"title": title, "content_base64": encoded, "message": message},
|
||||
)
|
||||
return self.get_page(title)
|
||||
|
||||
|
||||
def normalize_body(text: str) -> str:
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n"
|
||||
|
||||
|
||||
def parse_mirror(text: str) -> tuple[dict[str, str], str]:
|
||||
match = HEADER_PATTERN.match(text.replace("\r\n", "\n").replace("\r", "\n"))
|
||||
if match is None:
|
||||
raise WikiDocsError("缺少或损坏 gitea-wiki-mirror 元数据头")
|
||||
metadata: dict[str, str] = {}
|
||||
for line in match.group("metadata").splitlines():
|
||||
key, separator, value = line.partition(": ")
|
||||
if not separator or not key or not value:
|
||||
raise WikiDocsError(f"无效的镜像元数据行:{line}")
|
||||
metadata[key] = value
|
||||
return metadata, normalize_body(match.group("body"))
|
||||
|
||||
|
||||
def render_mirror(page: WikiPage, existing: str | None = None) -> str:
|
||||
synchronized_at: str | None = None
|
||||
if existing is not None:
|
||||
try:
|
||||
metadata, body = parse_mirror(existing)
|
||||
except WikiDocsError:
|
||||
pass
|
||||
else:
|
||||
if metadata.get("wiki_revision") == page.revision and body == page.text:
|
||||
synchronized_at = metadata.get("synchronized_at")
|
||||
if not synchronized_at:
|
||||
synchronized_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
header = "\n".join(
|
||||
(
|
||||
MIRROR_START,
|
||||
"generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)",
|
||||
f"wiki_page: {page.title}",
|
||||
f"wiki_url: {page.html_url}",
|
||||
f"wiki_revision: {page.revision}",
|
||||
f"synchronized_at: {synchronized_at}",
|
||||
MIRROR_END,
|
||||
)
|
||||
)
|
||||
return f"{header}\n\n{page.text}"
|
||||
|
||||
|
||||
def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]:
|
||||
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,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _write_atomic(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
||||
)
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(content)
|
||||
os.replace(temporary_name, path)
|
||||
except BaseException:
|
||||
Path(temporary_name).unlink(missing_ok=True)
|
||||
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}"]
|
||||
try:
|
||||
metadata, body = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError) as exc:
|
||||
return [f"镜像无效 {mapping.path}: {exc}"]
|
||||
expected = {
|
||||
"wiki_page": page.title,
|
||||
"wiki_url": page.html_url,
|
||||
"wiki_revision": page.revision,
|
||||
}
|
||||
errors = [
|
||||
f"{mapping.path} 的 {key} 不一致"
|
||||
for key, value in expected.items()
|
||||
if metadata.get(key) != value
|
||||
]
|
||||
if not metadata.get("synchronized_at"):
|
||||
errors.append(f"{mapping.path} 缺少 synchronized_at")
|
||||
if body != page.text:
|
||||
errors.append(f"{mapping.path} 的正文与 Wiki 不一致")
|
||||
return errors
|
||||
|
||||
|
||||
def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]:
|
||||
"""检查或写入所有显式映射;绝不处理映射外的文件。"""
|
||||
|
||||
if not check:
|
||||
dirty = dirty_mirror_paths(config)
|
||||
if dirty:
|
||||
details = "\n".join(dirty)
|
||||
raise WikiDocsError(
|
||||
"已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details
|
||||
)
|
||||
|
||||
messages: list[str] = []
|
||||
for mapping in config.mappings:
|
||||
page = client.get_page(mapping.page)
|
||||
target = ROOT / PurePosixPath(mapping.path)
|
||||
if check:
|
||||
errors = check_mirror(mapping, page, target)
|
||||
if errors:
|
||||
raise WikiDocsError("\n".join(errors))
|
||||
messages.append(f"一致:{mapping.path} <- {page.title}@{page.revision[:12]}")
|
||||
continue
|
||||
existing = target.read_text(encoding="utf-8") if target.is_file() else None
|
||||
rendered = render_mirror(page, existing)
|
||||
if existing != rendered:
|
||||
_write_atomic(target, rendered)
|
||||
messages.append(f"已更新:{mapping.path} <- {page.title}@{page.revision[:12]}")
|
||||
else:
|
||||
messages.append(f"无变化:{mapping.path} <- {page.title}@{page.revision[:12]}")
|
||||
return messages
|
||||
|
||||
|
||||
def append_mapping(config: Config, mapping: Mapping) -> None:
|
||||
raw = json.loads(config.path.read_text(encoding="utf-8"))
|
||||
mappings = validate_mappings(raw.get("mappings"))
|
||||
if any(item.page == mapping.page or item.path == mapping.path for item in mappings):
|
||||
raise WikiDocsError(f"页面或路径已经登记:{mapping.page} -> {mapping.path}")
|
||||
raw["mappings"].append({"page": mapping.page, "path": mapping.path})
|
||||
rendered = json.dumps(raw, ensure_ascii=False, indent=2) + "\n"
|
||||
_write_atomic(config.path, rendered)
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "dev_scripts"))
|
||||
|
||||
import harness # noqa: E402
|
||||
from harness import ( # noqa: E402
|
||||
CORE_DOCUMENT_REQUIREMENTS,
|
||||
CORE_PAGE_PATHS,
|
||||
REQUIRED_FILES,
|
||||
check_claude_code_entry,
|
||||
check_required_files,
|
||||
check_core_documents,
|
||||
check_agent_efficiency_rules,
|
||||
check_repository_readme,
|
||||
check_task_template,
|
||||
core_mapping_errors,
|
||||
missing_sections,
|
||||
)
|
||||
from wiki_docs import load_config # noqa: E402
|
||||
|
||||
|
||||
class CoreDocumentTests(unittest.TestCase):
|
||||
def test_current_core_documents_have_required_sections(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_core_documents(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_missing_sections_reports_each_heading(self) -> None:
|
||||
missing = missing_sections("# 页面\n## 已有\n", ("## 已有", "## 缺少"))
|
||||
self.assertEqual(missing, ["## 缺少"])
|
||||
|
||||
def test_every_core_document_is_required(self) -> None:
|
||||
for path in CORE_DOCUMENT_REQUIREMENTS:
|
||||
self.assertIn(path, REQUIRED_FILES)
|
||||
|
||||
def test_every_core_page_has_exact_mapping(self) -> None:
|
||||
config = load_config()
|
||||
mappings = {mapping.page: mapping.path for mapping in config.mappings}
|
||||
for page, path in CORE_PAGE_PATHS.items():
|
||||
self.assertEqual(mappings.get(page), path)
|
||||
|
||||
def test_task_archives_are_not_core_mappings(self) -> None:
|
||||
config = load_config()
|
||||
self.assertFalse(
|
||||
any(mapping.path.startswith("docs/task/") for mapping in config.mappings)
|
||||
)
|
||||
|
||||
def test_product_requirements_overview_is_core_document(self) -> None:
|
||||
path = "docs/09-product-requirements-overview.md"
|
||||
self.assertEqual(
|
||||
CORE_PAGE_PATHS.get("Product-Requirements-Overview"),
|
||||
path,
|
||||
)
|
||||
required = CORE_DOCUMENT_REQUIREMENTS[path]
|
||||
self.assertIn("## 当前需求索引", required)
|
||||
self.assertIn("## 原型与设计资产", required)
|
||||
self.assertIn("### 原型门禁", required)
|
||||
self.assertIn("### 线上原型与按需 HTML 快照", required)
|
||||
self.assertIn("### 原型确认记录", required)
|
||||
self.assertIn("## 更新时机", required)
|
||||
|
||||
def test_workflow_requires_ticket_and_design_evidence_gates(self) -> None:
|
||||
required = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"]
|
||||
self.assertIn("## 工单与设计证据双门禁", required)
|
||||
self.assertIn("### 先判断是否需要工单", required)
|
||||
self.assertIn("### 再判断设计证据", required)
|
||||
self.assertIn("### 线上原型审核与按需导出", required)
|
||||
self.assertIn("### 记录和重新确认", required)
|
||||
|
||||
def test_workflow_requires_online_wiki_initialization_gate(self) -> None:
|
||||
workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"]
|
||||
self.assertIn("## 新项目 Wiki 初始化门禁", workflow)
|
||||
|
||||
def test_workflow_requires_issue_only_task_record(self) -> None:
|
||||
workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"]
|
||||
self.assertIn("## 稳定文档与可选历史快照", workflow)
|
||||
|
||||
def test_workflow_requires_gitea_mcp_and_minimal_issue_reading(self) -> None:
|
||||
workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"]
|
||||
self.assertIn("## Gitea 交互与工单最小读取", workflow)
|
||||
|
||||
errors: list[str] = []
|
||||
check_agent_efficiency_rules(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_project_profile_requires_dev_harness_baseline(self) -> None:
|
||||
required = CORE_DOCUMENT_REQUIREMENTS["docs/00-project-profile.md"]
|
||||
self.assertIn("## DevHarness 来源与基线", required)
|
||||
|
||||
def test_missing_or_wrong_core_mapping_is_reported(self) -> None:
|
||||
errors = core_mapping_errors({"Home": "docs/wrong.md"})
|
||||
self.assertTrue(any("Home -> docs/README.md" in error for error in errors))
|
||||
self.assertTrue(
|
||||
any("Architecture-and-Code-Map" in error for error in errors)
|
||||
)
|
||||
|
||||
|
||||
class TaskTemplateTests(unittest.TestCase):
|
||||
def test_task_template_requires_document_impact(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_task_template(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_task_template_requires_delivery_document_impact(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text(
|
||||
"## 依赖与并行\n"
|
||||
"- 前置工单:无 / #编号\n"
|
||||
"- 是否允许与前置工单并行:是 / 否\n"
|
||||
"- 原因:\n"
|
||||
"## 原始需求\n"
|
||||
"- 来源:用户对话 / Gitea / 其他\n"
|
||||
"- 提出时间:\n"
|
||||
"- 关键原话或脱敏摘要:\n"
|
||||
"## 需求变化记录\n"
|
||||
"| 日期 | 变化内容 | 原因 | 用户确认 |\n"
|
||||
"## 文档影响\n"
|
||||
"- [ ] 不影响长期文档,原因:\n"
|
||||
"- [ ] 更新架构与代码地图\n"
|
||||
"- [ ] 更新业务规则与术语\n"
|
||||
"- [ ] 更新常见修改或故障排查\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 交付文档影响", errors)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- [ ] 无交付文档影响,原因:",
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_task_template_requires_design_and_prototype_gate(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text("## 基本信息\n", encoding="utf-8")
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 设计与原型门禁", errors)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- 可编辑设计源、线上原型链接和访问检查:",
|
||||
errors,
|
||||
)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求",
|
||||
errors,
|
||||
)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):",
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_task_template_requires_optional_snapshot_policy(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text("## 基本信息\n", encoding="utf-8")
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 任务记录与可选快照", errors)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- [ ] 默认不创建任务快照",
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_task_template_requires_subproject_impact(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text("## 基本信息\n", encoding="utf-8")
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 子项目影响", errors)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- 是否跨子项目:是 / 否",
|
||||
errors,
|
||||
)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- 是否修改共享接口或契约:是 / 否;唯一事实来源:",
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_task_template_requires_dependency_fields(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text(
|
||||
"## 文档影响\n"
|
||||
"- [ ] 不影响长期文档,原因:\n"
|
||||
"- [ ] 更新架构与代码地图\n"
|
||||
"- [ ] 更新业务规则与术语\n"
|
||||
"- [ ] 更新常见修改或故障排查\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 依赖与并行", errors)
|
||||
self.assertIn("单元任务模板缺少:- 前置工单:无 / #编号", errors)
|
||||
|
||||
def test_task_template_requires_requirement_traceability(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text(
|
||||
"## 依赖与并行\n"
|
||||
"- 前置工单:无 / #编号\n"
|
||||
"- 是否允许与前置工单并行:是 / 否\n"
|
||||
"- 原因:\n"
|
||||
"## 文档影响\n"
|
||||
"- [ ] 不影响长期文档,原因:\n"
|
||||
"- [ ] 更新架构与代码地图\n"
|
||||
"- [ ] 更新业务规则与术语\n"
|
||||
"- [ ] 更新常见修改或故障排查\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 原始需求", errors)
|
||||
self.assertIn("单元任务模板缺少:## 需求变化记录", errors)
|
||||
|
||||
|
||||
class AgentRuleTests(unittest.TestCase):
|
||||
def test_required_agent_rules_are_present(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_agent_efficiency_rules(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_repository_readme_requires_online_wiki_gate(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_repository_readme(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_repository_readme_rejects_missing_gate(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "README.md").write_text(
|
||||
"# 项目\n创建 Gitea 远端仓库\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_repository_readme(errors, root)
|
||||
self.assertTrue(any("README.md 缺少" in error for error in errors))
|
||||
|
||||
def test_claude_code_entry_imports_shared_rules(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_claude_code_entry(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_claude_code_entry_requires_exact_import_line(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "CLAUDE.md").write_text(
|
||||
"共同规则事实来源\n只记录 Claude Code 特有\n"
|
||||
"共同规则只修改 `AGENTS.md`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_claude_code_entry(errors, root)
|
||||
self.assertIn("CLAUDE.md 缺少独立的 @AGENTS.md 导入", errors)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "dev_scripts"))
|
||||
|
||||
from harness import ( # noqa: E402
|
||||
build_archive,
|
||||
existing_task_mirrors,
|
||||
export_task_archives,
|
||||
main as harness_main,
|
||||
safe_title,
|
||||
task_target,
|
||||
)
|
||||
from wiki_docs import ( # noqa: E402
|
||||
Config,
|
||||
Mapping,
|
||||
WikiClient,
|
||||
WikiDocsError,
|
||||
WikiPage,
|
||||
dirty_mirror_paths,
|
||||
load_config,
|
||||
parse_mirror,
|
||||
render_mirror,
|
||||
sync_all,
|
||||
validate_mappings,
|
||||
)
|
||||
|
||||
|
||||
class MappingTests(unittest.TestCase):
|
||||
def test_rejects_path_outside_docs(self) -> None:
|
||||
with self.assertRaisesRegex(WikiDocsError, "docs/"):
|
||||
validate_mappings([{"page": "Home", "path": "README.md"}])
|
||||
|
||||
def test_rejects_duplicate_page(self) -> None:
|
||||
with self.assertRaisesRegex(WikiDocsError, "重复映射"):
|
||||
validate_mappings(
|
||||
[
|
||||
{"page": "Home", "path": "docs/README.md"},
|
||||
{"page": "Home", "path": "docs/other.md"},
|
||||
]
|
||||
)
|
||||
|
||||
def test_normalizes_api_suffix_from_environment(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "wiki-docs.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"gitea_url": "http://configured.example",
|
||||
"owner": "owner",
|
||||
"repository": "repo",
|
||||
"mappings": [
|
||||
{"page": "Home", "path": "docs/README.md"}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.dict(
|
||||
os.environ, {"GITEA_URL": "http://gitea.example/api/v1"}, clear=False
|
||||
):
|
||||
config = load_config(path)
|
||||
self.assertEqual(config.gitea_url, "http://gitea.example")
|
||||
|
||||
|
||||
class MirrorTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = WikiPage(
|
||||
title="Home",
|
||||
sub_url="Home",
|
||||
text="# 首页\n",
|
||||
revision="a" * 40,
|
||||
html_url="http://gitea.example/o/r/wiki/Home",
|
||||
)
|
||||
|
||||
def test_render_includes_traceable_metadata(self) -> None:
|
||||
rendered = render_mirror(self.page)
|
||||
metadata, body = parse_mirror(rendered)
|
||||
self.assertEqual(metadata["wiki_page"], "Home")
|
||||
self.assertEqual(metadata["wiki_revision"], "a" * 40)
|
||||
self.assertTrue(metadata["synchronized_at"].endswith("Z"))
|
||||
self.assertEqual(body, "# 首页\n")
|
||||
|
||||
def test_unchanged_revision_preserves_sync_time(self) -> None:
|
||||
first = render_mirror(self.page)
|
||||
second = render_mirror(self.page, first)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
@patch("wiki_docs.subprocess.run")
|
||||
def test_dirty_mirror_paths_are_reported(self, run) -> None:
|
||||
run.return_value.stdout = " M docs/README.md\n"
|
||||
config = Config(
|
||||
path=Path("wiki-docs.json"),
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
self.assertEqual(dirty_mirror_paths(config), [" M docs/README.md"])
|
||||
|
||||
@patch("wiki_docs.dirty_mirror_paths", return_value=[" M docs/README.md"])
|
||||
def test_sync_stops_before_reading_wiki_when_mirror_is_dirty(self, _dirty) -> None:
|
||||
config = Config(
|
||||
path=Path("wiki-docs.json"),
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
client = Mock()
|
||||
with self.assertRaisesRegex(WikiDocsError, "未提交改动"):
|
||||
sync_all(config, client)
|
||||
client.get_page.assert_not_called()
|
||||
|
||||
|
||||
class WikiClientTests(unittest.TestCase):
|
||||
def test_encoded_unicode_sub_url_is_not_double_encoded(self) -> None:
|
||||
config = Config(
|
||||
path=Path("wiki-docs.json"),
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("中文", "docs/chinese.md"),),
|
||||
)
|
||||
client = WikiClient(config, token="")
|
||||
client.list_pages = Mock(
|
||||
return_value=[{"title": "中文", "sub_url": "%E4%B8%AD%E6%96%87.-"}]
|
||||
)
|
||||
encoded = __import__("base64").b64encode("# 中文\n".encode()).decode()
|
||||
with patch.object(
|
||||
client,
|
||||
"_request",
|
||||
return_value={
|
||||
"title": "中文",
|
||||
"content_base64": encoded,
|
||||
"last_commit": {"sha": "b" * 40},
|
||||
},
|
||||
) as request:
|
||||
page = client.get_page("中文")
|
||||
api_path = request.call_args.args[1]
|
||||
self.assertIn("%E4%B8%AD%E6%96%87.-", api_path)
|
||||
self.assertNotIn("%25E4", api_path)
|
||||
self.assertTrue(page.html_url.endswith("/%E4%B8%AD%E6%96%87.-"))
|
||||
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def test_safe_title_handles_windows_characters(self) -> None:
|
||||
self.assertEqual(safe_title(' 修复:"登录" / 超时 '), "修复-登录-超时")
|
||||
|
||||
def test_build_archive_replaces_known_fields(self) -> None:
|
||||
template = "# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n"
|
||||
result = build_archive(template, "12", "修复登录", "Task-12-login", "http://i/12")
|
||||
self.assertIn("# 12 修复登录", result)
|
||||
self.assertIn("http://i/12", result)
|
||||
self.assertIn("Task-12-login", result)
|
||||
self.assertNotIn("YYYY-MM-DD", result)
|
||||
|
||||
@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"
|
||||
original = json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"gitea_url": "http://gitea.example",
|
||||
"owner": "o",
|
||||
"repository": "r",
|
||||
"mappings": [
|
||||
{"page": "Home", "path": "docs/README.md"}
|
||||
],
|
||||
}
|
||||
)
|
||||
config_path.write_text(original, encoding="utf-8")
|
||||
client = client_class.return_value
|
||||
client.list_pages.return_value = []
|
||||
client.get_page.return_value = WikiPage(
|
||||
title="Task-Archive-Template",
|
||||
sub_url="Task-Archive-Template.-",
|
||||
text="# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n",
|
||||
revision="a" * 40,
|
||||
html_url="http://gitea.example/wiki/template",
|
||||
)
|
||||
client.create_page.return_value = WikiPage(
|
||||
title="Task-14-按需导出",
|
||||
sub_url="Task-14.-",
|
||||
text="# 14 按需导出\n",
|
||||
revision="b" * 40,
|
||||
html_url="http://gitea.example/wiki/task-14",
|
||||
)
|
||||
with patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"harness.py",
|
||||
"archive",
|
||||
"14",
|
||||
"按需导出",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
):
|
||||
result = harness_main()
|
||||
self.assertEqual(config_path.read_text(encoding="utf-8"), original)
|
||||
self.assertEqual(result, 0)
|
||||
client.create_page.assert_called_once()
|
||||
|
||||
def test_task_target_uses_stable_safe_name(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = task_target("Task-14-修复:导出", Path(directory))
|
||||
self.assertEqual(target.name, "14-修复-导出.md")
|
||||
|
||||
def test_existing_mirror_keeps_historical_custom_filename(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
path = root / "docs" / "task" / "2-初级维护者文档体系.md"
|
||||
path.parent.mkdir(parents=True)
|
||||
page = WikiPage(
|
||||
title="Task-2-Junior-Maintainer-Docs",
|
||||
sub_url="Task-2-Junior-Maintainer-Docs.-",
|
||||
text="# 2 文档\n",
|
||||
revision="c" * 40,
|
||||
html_url="http://gitea.example/wiki/task-2",
|
||||
)
|
||||
path.write_text(render_mirror(page), encoding="utf-8")
|
||||
mirrors = existing_task_mirrors(root)
|
||||
self.assertEqual(
|
||||
mirrors["Task-2-Junior-Maintainer-Docs"].name,
|
||||
"2-初级维护者文档体系.md",
|
||||
)
|
||||
|
||||
def test_existing_mirrors_ignore_frozen_pre_wiki_archives(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
task_dir = root / "docs" / "task"
|
||||
task_dir.mkdir(parents=True)
|
||||
legacy = task_dir / "10-历史归档.md"
|
||||
legacy.write_text(
|
||||
"# 10 历史归档\n\n这是接入 Wiki 前的冻结文件。\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertEqual(existing_task_mirrors(root), {})
|
||||
self.assertTrue(legacy.is_file())
|
||||
|
||||
@patch("harness.dirty_paths", return_value=[])
|
||||
def test_incremental_export_skips_same_revision(self, _dirty) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
path = root / "docs" / "task" / "14-按需导出.md"
|
||||
path.parent.mkdir(parents=True)
|
||||
page = WikiPage(
|
||||
title="Task-14-按需导出",
|
||||
sub_url="Task-14.-",
|
||||
text="# 14 按需导出\n",
|
||||
revision="d" * 40,
|
||||
html_url="http://gitea.example/wiki/task-14",
|
||||
)
|
||||
path.write_text(render_mirror(page), encoding="utf-8")
|
||||
client = Mock()
|
||||
client.list_pages.return_value = [
|
||||
{
|
||||
"title": page.title,
|
||||
"sub_url": page.sub_url,
|
||||
"last_commit": {"sha": page.revision},
|
||||
}
|
||||
]
|
||||
messages = export_task_archives(client, root=root)
|
||||
self.assertTrue(messages[0].startswith("跳过:"))
|
||||
client.get_page_from_metadata.assert_not_called()
|
||||
|
||||
@patch(
|
||||
"harness.dirty_paths",
|
||||
return_value=[" M docs/task/14-按需导出.md"],
|
||||
)
|
||||
def test_export_stops_before_wiki_read_when_task_mirror_is_dirty(
|
||||
self, _dirty
|
||||
) -> None:
|
||||
client = Mock()
|
||||
with self.assertRaisesRegex(WikiDocsError, "未提交改动"):
|
||||
export_task_archives(client)
|
||||
client.list_pages.assert_not_called()
|
||||
|
||||
@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)
|
||||
task_dir = root / "docs" / "task"
|
||||
task_dir.mkdir(parents=True)
|
||||
extra = task_dir / "99-历史快照.md"
|
||||
extra_page = WikiPage(
|
||||
title="Task-99-历史快照",
|
||||
sub_url="Task-99.-",
|
||||
text="# 99 历史快照\n",
|
||||
revision="e" * 40,
|
||||
html_url="http://gitea.example/wiki/task-99",
|
||||
)
|
||||
extra.write_text(render_mirror(extra_page), encoding="utf-8")
|
||||
page = WikiPage(
|
||||
title="Task-14-按需导出",
|
||||
sub_url="Task-14.-",
|
||||
text="# 14 按需导出\n",
|
||||
revision="f" * 40,
|
||||
html_url="http://gitea.example/wiki/task-14",
|
||||
)
|
||||
client = Mock()
|
||||
metadata = {
|
||||
"title": page.title,
|
||||
"sub_url": page.sub_url,
|
||||
"last_commit": {"sha": page.revision},
|
||||
}
|
||||
client.list_pages.return_value = [metadata]
|
||||
client.get_page_from_metadata.return_value = page
|
||||
messages = export_task_archives(client, export_all=True, root=root)
|
||||
exported = root / "docs" / "task" / "14-按需导出.md"
|
||||
self.assertTrue(exported.is_file())
|
||||
self.assertTrue(extra.is_file())
|
||||
self.assertTrue(messages[0].startswith("已导出:"))
|
||||
client.get_page_from_metadata.assert_called_once_with(metadata, page.title)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"gitea_url": "https://git.ilapage.cn",
|
||||
"owner": "OPC",
|
||||
"repository": "cmautobuy",
|
||||
"dev_harness_source": {
|
||||
"repository": "D:/OPC/dev_harness",
|
||||
"commit": "0b6ec7675dfc2d930a30527eddac83f4302d0879",
|
||||
"adopted_on": "2026-08-26"
|
||||
},
|
||||
"mappings": [
|
||||
{"page": "Home", "path": "docs/README.md"},
|
||||
{"page": "Project-Profile", "path": "docs/00-project-profile.md"},
|
||||
{"page": "Development-Workflow", "path": "docs/01-workflow.md"},
|
||||
{"page": "Architecture-and-Code-Map", "path": "docs/02-architecture-and-code-map.md"},
|
||||
{"page": "Business-Rules-and-Glossary", "path": "docs/03-business-rules-and-glossary.md"},
|
||||
{"page": "Local-Development-and-Verification", "path": "docs/04-local-development-and-verification.md"},
|
||||
{"page": "Common-Changes", "path": "docs/05-common-changes.md"},
|
||||
{"page": "Troubleshooting", "path": "docs/06-troubleshooting.md"},
|
||||
{"page": "Product-Requirements-Overview", "path": "docs/09-product-requirements-overview.md"},
|
||||
{"page": "Delivery-Documentation-Guide", "path": "docs/delivery/README.md"},
|
||||
{"page": "Audience-Document-Template", "path": "docs/delivery/audience-document-template.md"},
|
||||
{"page": "Task-Archive-Template", "path": "docs/templates/task-archive.md"}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user