693 lines
26 KiB
Python
693 lines
26 KiB
Python
"""DevHarness 单一命令行入口。
|
||
|
||
子命令:
|
||
check 检查必需文件、核心文档和既有任务快照结构
|
||
sync 从 Gitea Wiki 单向导出或校验核心 docs 镜像
|
||
archive 按用户明确要求在 Gitea Wiki 创建可选任务快照
|
||
export 人工按需把已有任务快照导出到 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,
|
||
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/07-mvp-requirements.md",
|
||
"Android-Agent-API-Contract": "docs/08-agent-api-contract.md",
|
||
"Delivery-Issues": "docs/09-delivery-issues.md",
|
||
"OnePlus-Real-Device-Acceptance": "docs/10-real-device-acceptance.md",
|
||
"PDD-Detail-Rule-Migration-Analysis": (
|
||
"docs/11-pdd-detail-rule-migration-analysis.md"
|
||
),
|
||
"Deployment-Template": "docs/templates/deployment.md",
|
||
"Task-Archive-Template": "docs/templates/task-archive.md",
|
||
}
|
||
# 按 GoAuto 实际文档结构定义,不照抄 DevHarness 模板章节名。
|
||
# 只登记结构性、稳定的小标题;随业务演进的具体条目(如故障现象、单条业务规则)
|
||
# 不纳入校验,避免文档正常更新即触发 check 失败。
|
||
CORE_DOCUMENT_REQUIREMENTS = {
|
||
"docs/README.md": (
|
||
"## 建议阅读顺序",
|
||
"## 事实来源",
|
||
"## 权威源与事实边界",
|
||
"## 五分钟检查",
|
||
"## 同步与归档",
|
||
),
|
||
"docs/00-project-profile.md": (
|
||
"## 基本信息",
|
||
"## 建设基线",
|
||
"## 交付单元",
|
||
"## 文档事实来源",
|
||
"## 环境与凭据",
|
||
"## 当前阶段",
|
||
),
|
||
"docs/01-workflow.md": (
|
||
"## 事实来源",
|
||
"## 权威源与事实边界",
|
||
"## Gitea 交互与工单最小读取",
|
||
"## 新项目 Wiki 初始化门禁",
|
||
"## 工单与设计证据双门禁",
|
||
"### 线上原型审核与按需导出",
|
||
"## 任务层级与状态",
|
||
"## 单元任务闭环",
|
||
"## 标准任务节奏",
|
||
"## 需求记录与流转",
|
||
"## 自然语言快捷指令",
|
||
"## 效率与范围控制",
|
||
"## 文档影响",
|
||
),
|
||
"docs/02-architecture-and-code-map.md": (
|
||
"## MVP 架构",
|
||
"## 最小闭环",
|
||
"## 关键设计",
|
||
"## 最小业务数据",
|
||
"## 已建立的工程入口",
|
||
),
|
||
"docs/03-business-rules-and-glossary.md": (
|
||
"## 当前范围",
|
||
"## 自动化边界",
|
||
"## 术语",
|
||
),
|
||
"docs/04-local-development-and-verification.md": (
|
||
"## Windows PowerShell 与 UTF-8",
|
||
"## 通用检查",
|
||
"## 服务端验证",
|
||
"## Web 验证",
|
||
"## Android 验证",
|
||
"## 原型验证",
|
||
),
|
||
"docs/05-common-changes.md": (
|
||
"## 风险分级",
|
||
"## 更新文档",
|
||
"## 验收 Agent 修改",
|
||
),
|
||
"docs/06-troubleshooting.md": (
|
||
"## 排查顺序",
|
||
),
|
||
"docs/07-mvp-requirements.md": (
|
||
"## 长期需求索引",
|
||
),
|
||
}
|
||
REQUIRED_FILES = (
|
||
"AGENTS.md",
|
||
"CLAUDE.md",
|
||
"README.md",
|
||
"docs/00-project-profile.md",
|
||
"docs/01-workflow.md",
|
||
"docs/templates/deployment.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:
|
||
task_dir = ROOT / "docs" / "task"
|
||
for path in task_dir.glob("*.md"):
|
||
if not re.match(r"^\d+-.+\.md$", path.name):
|
||
errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}")
|
||
content = path.read_text(encoding="utf-8")
|
||
try:
|
||
metadata, _ = parse_mirror(content)
|
||
except WikiDocsError as exc:
|
||
errors.append(f"{path.name} 的任务镜像无效:{exc}")
|
||
continue
|
||
if re.fullmatch(r"Task-\d+-.+", metadata.get("wiki_page", "")) is None:
|
||
errors.append(f"{path.name} 的 wiki_page 不是任务归档页面")
|
||
if re.fullmatch(r"[0-9a-f]{40,64}", metadata.get("wiki_revision", "")) is None:
|
||
errors.append(f"{path.name} 的 wiki_revision 无效")
|
||
for heading in ARCHIVE_HEADINGS:
|
||
if heading not in content:
|
||
errors.append(f"{path.name} 缺少章节:{heading}")
|
||
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 原型或无需任何原型的原因:",
|
||
"## 文档影响",
|
||
"- [ ] 不影响长期文档,原因:",
|
||
"- [ ] 更新架构与代码地图",
|
||
"- [ ] 更新业务规则与术语",
|
||
"- [ ] 更新常见修改或故障排查",
|
||
"单次任务需求、变化、实现、测试、提交和验收只记录在本工单",
|
||
"标准流程不创建 Wiki 任务归档",
|
||
"## 交付文档影响",
|
||
"- [ ] 无交付文档影响,原因:",
|
||
"- [ ] 更新已有交付文档,受众与页面:",
|
||
"- [ ] 新增交付文档,受众与页面:",
|
||
"- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:",
|
||
"## 任务记录与可选快照",
|
||
"- 单次任务事实来源:当前 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 工单是单次任务需求、变化、实现、测试、提交和验收的唯一事实来源",
|
||
"Wiki 同步由长期事实变化触发,不由任务完成触发",
|
||
"标准流程不创建 Wiki 任务归档",
|
||
"工单默认只在开始实施、集中回写待验收、验收关闭三个节点更新",
|
||
"### Gitea 交互与工单最小读取",
|
||
"查询、创建、更新、评论、状态变更及关闭操作",
|
||
"优先关注当前状态、最新评论和首个未完成步骤",
|
||
"连接器不支持评论分页或增量读取时允许读取完整工单",
|
||
"不得为规避完整读取而新增本地工单、缓存或第二事实来源",
|
||
"「当前实现」",
|
||
"「目标契约」",
|
||
"### 新项目 Wiki 初始化门禁",
|
||
"`Home` 不存在时必须先创建 `Home`",
|
||
"不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据",
|
||
"提交只包含当前工单相关文件",
|
||
"不得仅为设置编码重复启动一层 PowerShell",
|
||
"文件解码和控制台输出分别处理",
|
||
"不得默认使用 `-ExecutionPolicy Bypass`",
|
||
"### 工单与设计证据双门禁",
|
||
"新页面、独立用户功能、重大交互或导航变化",
|
||
"`prototypes/<工单号>/<版本>/index.html`",
|
||
"默认直接通过 QuantUX 或其他设计工具的线上链接审核",
|
||
"已确认的本地快照不得原位覆盖",
|
||
"`导出原型 #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:
|
||
"""检查产品 README 提供阅读入口与验证方式。
|
||
|
||
与 DevHarness 模板的差异(按 GoAuto 事实改写,见 #47):
|
||
模板此处校验的是「新项目快速开始」中的线上 Wiki 初始化顺序,面向从模板
|
||
派生新仓库的场景。GoAuto 是已建成的产品仓库,Wiki 已于 2026-08-17 初始化,
|
||
其 README 面向本项目的使用者与维护者,写入建仓步骤会误导读者。
|
||
因此本函数只校验产品 README 应有的结构;新项目 Wiki 初始化门禁改由
|
||
AGENTS.md 与 docs/01-workflow.md 承载,两处仍由 check 强制校验。
|
||
"""
|
||
|
||
path = root / "README.md"
|
||
if not path.is_file():
|
||
return
|
||
content = path.read_text(encoding="utf-8")
|
||
required = (
|
||
"## 阅读入口",
|
||
"## 当前状态",
|
||
"## 验证",
|
||
)
|
||
for section in missing_sections(content, required):
|
||
errors.append(f"README.md 缺少:{section}")
|
||
|
||
|
||
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"):
|
||
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)
|
||
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())
|