From b08a919fd7ce2276f76fdd1f52c9d8741a8341cb Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 7 Aug 2026 23:57:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=20Wiki=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E9=95=9C=E5=83=8F=E6=B5=81=E7=A8=8B=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 22 +- CLAUDE.md | 5 +- README.md | 25 ++- docs/00-project-profile.md | 56 +++-- docs/01-workflow.md | 72 ++++-- docs/README.md | 49 +++- docs/templates/task-archive.md | 10 + scripts/check_harness.py | 43 ++++ scripts/new_task_archive.py | 101 ++++++--- scripts/sync_wiki_docs.py | 33 +++ scripts/wiki_docs.py | 394 +++++++++++++++++++++++++++++++++ tests/test_wiki_docs.py | 117 ++++++++++ wiki-docs.json | 24 ++ 13 files changed, 859 insertions(+), 92 deletions(-) create mode 100644 scripts/sync_wiki_docs.py create mode 100644 scripts/wiki_docs.py create mode 100644 tests/test_wiki_docs.py create mode 100644 wiki-docs.json diff --git a/AGENTS.md b/AGENTS.md index 44efe61..9974779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,12 @@ # Agent 开发规则 -本仓库采用 DevHarness 工作流:Gitea 工单是实施期间的事实来源,Git 是代码变更记录,`docs/task` 是完成后的最终归档。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。 +本仓库采用 DevHarness 工作流:Gitea 工单是任务过程的事实来源,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 是代码与版本绑定资料的变更记录,`docs/` 只保存 Wiki 的只读镜像。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。 开始工作前先阅读 [项目档案](docs/00-project-profile.md) 和任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。 ## 1. 永久规则 -- 不把密码、令牌、Cookie、私钥、个人数据或生产数据写入代码、日志、工单和文档。 +- 不把密码、令牌、Cookie、私钥、个人数据或生产数据写入代码、日志、工单、Wiki 和文档。 - 不执行未经用户明确授权的发布、付款、删除数据、破坏性迁移或其他不可逆操作。 - 保留用户已有和任务无关的工作区改动,不擅自重置、覆盖或混入提交。 - 发现需求与安全规则、已确认方案或现有数据冲突时,先停止实施并说明影响。 @@ -36,6 +36,7 @@ 5. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。 6. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。 7. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。 +8. 长期文档必须先修改 Wiki、读取确认,再运行 `python scripts/sync_wiki_docs.py` 导出本地镜像;不得直接编辑 `docs/` 后反向覆盖 Wiki。 Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。 @@ -61,6 +62,7 @@ Epic:完整产品目标和长期路线 - 变化影响 MVP 或 Epic 时,同时更新父工单。 - 会改变用户已确认结果的变化,更新工单后必须再次等待用户确认。 - 阻塞、失败方案和新发现根因不能只留在聊天或代码注释中。 +- Wiki 页面删除、重命名或事实源边界变化时,必须先更新工单并等待用户确认;同步工具不得自动传播删除或重命名。 - 工单状态应使用:待确认、待实施、进行中、阻塞、待验收、已完成。 ## 6. Git 与验证 @@ -76,10 +78,11 @@ Epic:完整产品目标和长期路线 1. 实现完成后逐项检查验收标准,并提交代码。 2. 更新单元工单:最终方案、方案差异、测试结果、提交哈希和遗留问题。 3. 工单保持“待验收”,用户没有明确验收通过前不得关闭。 -4. 按 `docs/templates/task-archive.md` 创建 `docs/task/<编号>-<短标题>.md`。 -5. 归档文档单独提交,例如:`docs: 归档任务 #123`。 -6. 把归档路径和提交哈希回写工单。 -7. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。 +4. 运行 `python scripts/new_task_archive.py <编号> "<短标题>"`,先在 Wiki 创建任务归档,再登记映射并导出 `docs/task/<编号>-<短标题>.md` 镜像。 +5. 读取确认 Wiki 页面,运行 `python scripts/sync_wiki_docs.py --check` 校验镜像。 +6. 归档镜像单独提交,例如:`docs: 归档任务 #123`。 +7. 把 Wiki 页面、revision、镜像路径和提交哈希回写工单。 +8. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。 MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。 @@ -89,7 +92,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - 类和函数保持单一职责,名称表达业务含义。 - 注释解释原因、边界和风险,不逐行翻译代码。 - 错误必须可定位,不静默吞掉失败。 -- 文档先写结论和用途,再写步骤;示例命令应可直接复制。 +- Wiki 文档先写结论和用途,再写步骤;示例命令应可直接复制,本地 `docs/` 由同步工具生成。 - 面向初级维护者说明从哪里开始读、怎样运行和怎样验证。 ## 9. 引导提交例外 @@ -102,4 +105,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 -- 尚未配置。开始产品开发前必须填写项目档案,并删除本行。 +- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。 +- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。 +- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。 +- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。 diff --git a/CLAUDE.md b/CLAUDE.md index 71366ac..0c3cb05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Claude Code 开始任何工作前,必须按顺序阅读: 1. 根目录的 `AGENTS.md`; -2. `docs/00-project-profile.md`; +2. Gitea Wiki 的项目档案;Wiki 不可用时读取 `docs/00-project-profile.md` 镜像并明确其 revision; 3. 任务涉及目录中更具体的 `AGENTS.md`; 4. 当前 Gitea 工单及其父级 MVP、Epic 工单。 @@ -13,7 +13,8 @@ Claude Code 开始任何工作前,必须按顺序阅读: - 方案经用户确认并建立单元任务工单后,才能修改产品代码。 - 只修改当前工单范围内的文件,保留用户已有和无关的改动。 -- 实现、测试、Git 提交、待验收、任务归档和关闭工单的顺序不得跳过。 +- 实现、测试、Git 提交、待验收、Wiki 任务归档、导出本地镜像和关闭工单的顺序不得跳过。 +- 长期文档先修改 Wiki,再导出 `docs/`;不得直接编辑镜像作为最终结果。 - 未经用户明确验收,不得关闭工单。 - 不得把密码、令牌、Cookie、私钥或生产数据写入代码、日志、工单和文档。 diff --git a/README.md b/README.md index eb972a9..b97b0aa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DevHarness -DevHarness 是一个以 Gitea 工单为事实来源、以 Git 提交为变更记录、由人负责确认和验收的 AI 辅助开发模板。 +DevHarness 是一个以 Gitea 工单管理任务过程、以 Gitea Wiki 管理长期文档、以 Git 提交记录代码变更、由人负责确认和验收的 AI 辅助开发模板。 它约束的是开发过程,不限制项目使用 Python、Go、JavaScript 或其他技术栈。 @@ -14,18 +14,19 @@ DevHarness 是一个以 Gitea 工单为事实来源、以 Git 提交为变更记 -> Agent 实现并测试 -> 提交代码并更新工单 -> 人工验收 - -> 归档 docs/task + -> 先归档 Wiki,再导出 docs/task 镜像 -> 关闭工单并更新父工单 ``` ## 快速开始 1. 复制或克隆本仓库,并修改仓库名称。 -2. 填写 [项目档案](docs/00-project-profile.md),特别是 Gitea 地址、仓库名和验证命令。 +2. 在 Gitea Wiki 填写项目档案,再运行 `python scripts/sync_wiki_docs.py` 导出 [本地镜像](docs/00-project-profile.md)。 3. 把项目不可违反的安全规则写入根目录或子项目的 `AGENTS.md`。 4. 创建 Gitea 远端仓库并推送当前引导提交。 -5. 使用 `.gitea/issue_template/` 中的模板创建第一个 Epic、MVP 和单元任务。 -6. 开始产品代码前运行: +5. 配置 `wiki-docs.json` 的页面映射;读取 Wiki 可匿名访问或使用 `GITEA_TOKEN`,写 Wiki 必须通过环境变量提供令牌。 +6. 使用 `.gitea/issue_template/` 中的模板创建第一个 Epic、MVP 和单元任务。 +7. 开始产品代码前运行: ```powershell python scripts/check_harness.py --strict @@ -39,18 +40,20 @@ DevHarness 是一个以 Gitea 工单为事实来源、以 Git 提交为变更记 AGENTS.md Agent 的通用工作规则 CLAUDE.md Claude Code 的规则入口 .gitea/issue_template/ Epic、MVP、单元任务工单模板 -docs/00-project-profile.md 每个项目需要填写的档案 -docs/01-workflow.md 人和 Agent 都能阅读的流程说明 -docs/templates/task-archive.md 完成后的本地归档模板 -docs/task/ 已完成任务的最终记录 +docs/00-project-profile.md Wiki 项目档案的只读镜像 +docs/01-workflow.md Wiki 开发工作流的只读镜像 +docs/templates/task-archive.md Wiki 任务归档模板的只读镜像 +docs/task/ Wiki 任务归档页的只读镜像 +wiki-docs.json Wiki 页面到本地镜像的显式映射 scripts/check_harness.py 模板和归档的最小自检 -scripts/new_task_archive.py 创建任务归档文件 +scripts/sync_wiki_docs.py 单向导出或检查 Wiki 镜像 +scripts/new_task_archive.py 先创建 Wiki 任务归档,再导出镜像 ``` ## 设计原则 - 人决定目标、范围和验收结果,Agent 负责检查、实现和验证。 -- 工单记录实施过程,`docs/task` 只保存完成后的最终事实。 +- 工单记录实施过程,Wiki 保存长期有效的最终事实,`docs/` 只保存可审查的镜像。 - 一个单元工单只解决一个可独立测试和回退的问题。 - 实现提交与归档提交分开,便于审查与追溯。 - 凭据、个人数据和生产数据不得进入代码、工单或归档。 diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 0ec62e3..f0bd89e 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -1,23 +1,32 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Project-Profile +wiki_url: http://ilaer.eicp.net:8418/opc/dev_harness/wiki/Project-Profile.- +wiki_revision: 845266496196a4fc33462dfda779d0f899fa4c18 +synchronized_at: 2026-08-07T15:53:58Z + + # 项目档案 -复制模板后先填写本页。这里保存不经常变化、所有维护者都需要知道的信息。 +本页记录不经常变化、所有维护者都需要知道的信息。它是项目档案的事实来源;仓库内 `docs/00-project-profile.md` 是只读镜像。 ## 基本信息 | 项目 | 内容 | |---|---| -| 项目名称 | `<填写>` | -| 一句话目标 | `<填写>` | -| Gitea 地址 | `<例如 https://gitea.example.com>` | -| 仓库 | `` | +| 项目名称 | DevHarness | +| 一句话目标 | 提供以 Gitea 工单、Wiki 和 Git 为事实来源的 AI 辅助开发工作流模板 | +| Gitea 地址 | http://ilaer.eicp.net:8418 | +| 仓库 | `opc/dev_harness` | | 默认分支 | `main` | -| 主要维护者 | `<填写>` | +| 主要维护者 | `ila` | ## 技术栈 | 部分 | 技术 | 规则文件 | |---|---|---| -| `<子项目或服务>` | `<语言、框架、版本>` | `<路径/AGENTS.md>` | +| Harness 规则和模板 | Markdown、Gitea | `AGENTS.md` | +| Wiki 镜像与结构检查 | Python 3 标准库 | `AGENTS.md` | ## 常用命令 @@ -25,26 +34,35 @@ | 用途 | 命令 | 预期结果 | |---|---|---| -| 安装依赖 | `<填写>` | `<填写>` | -| 启动开发环境 | `<填写>` | `<填写>` | -| 格式检查 | `<填写>` | `<填写>` | -| 静态检查 | `<填写>` | `<填写>` | -| 单元测试 | `<填写>` | `<填写>` | -| 集成测试 | `<填写或写“不适用”>` | `<填写>` | +| 检查模板结构 | `python scripts/check_harness.py --strict` | 输出“DevHarness 检查通过” | +| 运行单元测试 | `python -m unittest discover -s tests -v` | 所有测试通过 | +| 导出 Wiki 镜像 | `python scripts/sync_wiki_docs.py` | 映射页面写入 `docs/` | +| 检查 Wiki 镜像 | `python scripts/sync_wiki_docs.py --check` | 输出镜像与 Wiki 一致 | +| 创建任务归档 | `python scripts/new_task_archive.py 123 "修复登录超时"` | 先创建 Wiki 归档页,再登记并导出本地镜像 | ## 目录边界 | 目录 | 职责 | 不应放入 | |---|---|---| -| `<路径>` | `<填写>` | `<填写>` | +| `.gitea/issue_template/` | Gitea 工单模板 | 凭据、任务最终归档 | +| `docs/` | Wiki 自动导出的只读镜像 | 人工直接维护的长期文档 | +| `docs/task/` | Wiki 任务归档页的只读镜像 | 讨论过程和临时方案 | +| `scripts/` | Harness 检查、Wiki 同步和归档工具 | 产品功能代码 | +| `tests/` | Harness 工具自动化测试 | 生产数据 | ## 环境与凭据 -- 本地配置文件:`<填写>` -- 配置示例文件:`<填写>` -- 凭据保存位置:`<只写保存方式,不填写真实凭据>` -- 日志和构建产物位置:`<填写>` +- Wiki 同步配置:仓库根目录 `wiki-docs.json`。 +- Gitea 地址可由配置提供,也可通过 `GITEA_URL` 覆盖。 +- Gitea Personal Access Token 仅通过 `GITEA_TOKEN` 环境变量提供,不写入仓库。 +- Token 至少需要读取仓库权限;创建 Wiki 任务归档时还需要写仓库权限。 +- 日志和构建产物:本项目不持久化运行日志;Python 缓存不提交。 ## 项目专用验收要求 -- `<填写>` +- 长期文档必须先更新 Wiki,再导出本地镜像。 +- 镜像必须包含来源页面、revision 和同步时间。 +- 页面删除、重命名和映射变更必须人工确认。 +- `python scripts/check_harness.py --strict` 必须通过。 +- `python -m unittest discover -s tests -v` 必须通过。 +- 未执行或无法覆盖的验证必须记录到工单。 diff --git a/docs/01-workflow.md b/docs/01-workflow.md index f34611f..64d79c0 100644 --- a/docs/01-workflow.md +++ b/docs/01-workflow.md @@ -1,14 +1,29 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Development-Workflow +wiki_url: http://ilaer.eicp.net:8418/opc/dev_harness/wiki/Development-Workflow.- +wiki_revision: ae9f9aac5df77f1e3e1c2a34bd00c849c1f63cbf +synchronized_at: 2026-08-07T15:54:03Z + + # 开发工作流 +## 事实来源边界 + +- Gitea 工单记录任务状态、讨论、阻塞、方案变化、验证和验收。 +- Gitea Wiki 保存架构说明、开发规范、操作手册和完成后的任务归档。 +- Git 保存源码、与特定代码版本强绑定的文档,以及 Wiki 的本地镜像。 +- 本地 `docs/` 仅供浏览和审查,不是长期文档编辑入口。 + ## 一次任务怎样完成 ### 1. 讨论 -用户描述需求或故障。Agent 先检查现状,再给出目标、非目标、方案、风险、回退和验证方法。存在不同实现方向时,说明取舍,让用户确认。 +用户描述需求或故障。Agent 先检查现状,再给出目标、非目标、方案、风险、回退和验证方法。存在不同实现方向时,说明取舍并等待用户确认。 ### 2. 建单 -方案确认后,使用 `.gitea/issue_template/task.md` 创建单元任务工单。没有工单号之前不修改产品代码。 +方案确认后,使用 `.gitea/issue_template/task.md` 创建单元任务工单。没有工单号之前不修改产品代码或正式文档。 新产品或较大版本先建立 Epic,再建立 MVP: @@ -24,29 +39,49 @@ ### 3. 实施 -Agent 检查工作区,只修改工单范围内的文件。发现新问题时先记录到工单;如果它不影响当前验收,另建工单,不扩大当前任务。 +Agent 检查分支和工作区,只修改工单范围内的文件。发现新问题时先记录到工单;如果不影响当前验收,则另建工单,不扩大当前任务。 -重要进度应及时写回工单: +重要进度及时写回工单: - 已确认的根因; - 方案或范围变化; - 测试结果; - 阻塞和未验证内容; -- Git 提交哈希。 +- Git 提交哈希; +- 相关 Wiki 页面及 revision。 + +长期文档遵循唯一顺序: + +```text +修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像 +``` + +不得先编辑 `docs/` 再反向覆盖 Wiki。 ### 4. 待验收 -实现和测试完成后,Agent 提交代码并将工单更新为待验收。用户验收前工单保持开启。 +实现和测试完成后,Agent 提交实现代码并将工单更新为“待验收”。用户验收前工单保持开启。 ### 5. 归档和关闭 -使用以下命令创建归档草稿: +使用以下命令在 Wiki 创建任务归档页、登记显式映射并导出本地镜像: ```powershell python scripts/new_task_archive.py 123 "修复登录超时" ``` -填写实际结果后单独提交归档,再把路径和提交哈希写回工单。用户明确验收通过后,关闭单元工单并勾选父工单中的任务。 +归档内容以 Wiki 页面为主源;本地 `docs/task/<编号>-<短标题>.md` 是镜像。归档镜像单独提交,再把 Wiki 页面、revision、镜像路径和提交哈希写回工单。用户明确验收通过后,关闭单元工单并勾选父工单中的任务。 + +## 文档同步规则 + +- 映射保存在 `wiki-docs.json`,每个 Wiki 页面对应唯一仓库路径。 +- 同步脚本只实现 Wiki → `docs/`,不提供反向同步。 +- 镜像头必须记录页面名、页面地址、revision 和同步时间。 +- 已跟踪镜像存在未提交改动时,同步必须停止;确认改动来源后再处理。 +- `--check` 只检查,不写文件;页面缺失、revision 不一致或正文不一致均失败。 +- 页面删除和重命名不会自动传播,必须先更新工单并人工确认映射变化。 +- Wiki 更新成功而导出失败时,在工单记录部分完成状态,不得把任务标为完成。 +- 与具体代码版本强绑定的接口或迁移资料可直接随代码维护,但必须在 Wiki 提供入口或适用版本说明。 ## 什么时候重新确认方案 @@ -56,17 +91,18 @@ python scripts/new_task_archive.py 123 "修复登录超时" - 增加或删除接口、数据库字段或迁移; - 安全边界、权限或不可逆操作发生变化; - 原方案不可行,需要更换主要技术路线; -- 任务范围明显扩大。 +- 任务范围明显扩大; +- Wiki 页面删除、重命名或事实源边界改变。 普通内部实现细节不需要反复确认,但重要取舍应记录在工单中。 -## 工单与文档分别写什么 +## 工单、Wiki 与 Git 分别写什么 -| 信息 | Gitea 工单 | `docs/task` | -|---|---:|---:| -| 讨论过程和临时方案 | 是 | 否 | -| 实施进度和阻塞 | 是 | 否 | -| 最终实现方案 | 是 | 是 | -| 测试结果与未验证内容 | 是 | 是 | -| 提交哈希 | 是 | 是 | -| 长期有效的最终结论 | 可链接 | 是 | +| 信息 | Gitea 工单 | Gitea Wiki | Git / `docs` 镜像 | +|---|---:|---:|---:| +| 讨论过程和临时方案 | 是 | 否 | 否 | +| 实施进度和阻塞 | 是 | 否 | 否 | +| 长期有效的最终方案 | 链接 | 是 | 镜像 | +| 测试结果与未验证内容 | 是 | 任务归档 | 镜像 | +| 提交哈希 | 是 | 任务归档 | 镜像 | +| 与具体代码版本绑定的说明 | 可链接 | 提供入口 | 是 | diff --git a/docs/README.md b/docs/README.md index 79c3c45..5fcefb3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,45 @@ -# 文档索引 + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Home +wiki_url: http://ilaer.eicp.net:8418/opc/dev_harness/wiki/Home +wiki_revision: ff8425429da943ad2ce9380d950458dc49a72658 +synchronized_at: 2026-08-07T15:53:56Z + -- [项目档案](00-project-profile.md):仓库、技术栈、命令和负责人等稳定信息。 -- [开发工作流](01-workflow.md):从需求讨论到工单关闭的完整顺序。 -- [任务归档模板](templates/task-archive.md):任务完成后的固定格式。 -- `task/`:已经完成并与代码版本对应的任务记录。 +# DevHarness 文档中心 -临时进度、方案讨论和待办事项写入 Gitea 工单,不写进长期文档。 +DevHarness 是一个以 Gitea 工单管理开发任务、以 Wiki 管理长期开发文档、以 Git 记录代码变更的 AI 辅助开发模板。 + +## 事实来源 + +| 信息 | 事实来源 | +|---|---| +| 任务状态、讨论、阻塞、验收过程 | Gitea 工单 | +| 架构说明、开发规范、操作手册、任务归档 | Gitea Wiki | +| 源码和与特定代码版本强绑定的文档 | Git 仓库 | +| 离线浏览副本 | Git 仓库中的 `docs/` Wiki 镜像 | + +本地 `docs/` 不是编辑入口。长期文档必须先修改 Wiki,读取确认后再导出镜像。 + +## 文档入口 + +- [项目档案](Project-Profile) +- [开发工作流](Development-Workflow) +- [任务归档模板](Task-Archive-Template) +- [Gitea 工单](http://ilaer.eicp.net:8418/opc/dev_harness/issues) +- [代码仓库](http://ilaer.eicp.net:8418/opc/dev_harness) + +## 同步原则 + +固定顺序: + +```text +修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像 +``` + +- 页面和本地路径通过仓库中的 `wiki-docs.json` 显式映射。 +- 镜像头记录来源页面、Wiki revision 和同步时间。 +- 同步工具发现已跟踪镜像存在未提交修改时必须停止。 +- 页面删除、重命名和映射变更必须人工确认,不自动传播。 +- Wiki 或导出失败时,相关任务不能标记为完成。 +- 凭据、个人数据和生产数据不得进入 Wiki 或镜像。 diff --git a/docs/templates/task-archive.md b/docs/templates/task-archive.md index 66061ac..8e00125 100644 --- a/docs/templates/task-archive.md +++ b/docs/templates/task-archive.md @@ -1,3 +1,11 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-Archive-Template +wiki_url: http://ilaer.eicp.net:8418/opc/dev_harness/wiki/Task-Archive-Template.- +wiki_revision: 8f8d1bb9c596a79d37631d670aaf95818d74e7c8 +synchronized_at: 2026-08-07T15:54:05Z + + # <工单号> <标题> - 类型:需求 / 缺陷 / 重构 @@ -6,6 +14,8 @@ - 状态:待验收 / 已完成 - 日期:YYYY-MM-DD - Gitea 工单:<链接> +- Wiki 页面:<页面名> +- Wiki revision:见本地镜像头 ## 背景与目标 diff --git a/scripts/check_harness.py b/scripts/check_harness.py index 0859962..9fea9f1 100644 --- a/scripts/check_harness.py +++ b/scripts/check_harness.py @@ -6,6 +6,8 @@ import argparse import re from pathlib import Path +from wiki_docs import WikiDocsError, load_config, parse_mirror + ROOT = Path(__file__).resolve().parents[1] REQUIRED_FILES = ( @@ -14,6 +16,9 @@ REQUIRED_FILES = ( "docs/00-project-profile.md", "docs/01-workflow.md", "docs/templates/task-archive.md", + "wiki-docs.json", + "scripts/wiki_docs.py", + "scripts/sync_wiki_docs.py", ".gitea/issue_template/epic.md", ".gitea/issue_template/mvp.md", ".gitea/issue_template/task.md", @@ -56,6 +61,43 @@ def check_archives(errors: list[str]) -> None: errors.append(f"{path.name} 没有记录未验证部分") +def check_wiki_mirrors(errors: list[str]) -> None: + """检查每份本地文档都有显式映射和可追踪的镜像头。""" + + try: + config = load_config() + except WikiDocsError as exc: + errors.append(str(exc)) + return + + mapped_paths = {mapping.path for mapping in config.mappings} + actual_paths = { + path.relative_to(ROOT).as_posix() for path in (ROOT / "docs").rglob("*.md") + } + 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 main() -> int: parser = argparse.ArgumentParser(description="检查 DevHarness 项目结构") parser.add_argument( @@ -69,6 +111,7 @@ def main() -> int: warnings: list[str] = [] check_required_files(errors) check_project_profile(errors, warnings, args.strict) + check_wiki_mirrors(errors) check_archives(errors) for warning in warnings: diff --git a/scripts/new_task_archive.py b/scripts/new_task_archive.py index 538e372..a1a01ad 100644 --- a/scripts/new_task_archive.py +++ b/scripts/new_task_archive.py @@ -1,4 +1,4 @@ -"""根据模板创建任务归档草稿。""" +"""先在 Gitea Wiki 创建任务归档,再登记并导出本地镜像。""" from __future__ import annotations @@ -7,50 +7,95 @@ import re from datetime import date from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -TEMPLATE = ROOT / "docs" / "templates" / "task-archive.md" -TASK_DIR = ROOT / "docs" / "task" +from wiki_docs import ( + DEFAULT_CONFIG, + Mapping, + WikiClient, + WikiDocsError, + append_mapping, + load_config, + sync_all, +) 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 create_archive(issue_number: str, title: str) -> Path: - """创建归档草稿;目标文件存在时拒绝覆盖。""" - - short_title = safe_title(title) - if not issue_number.isdigit(): - raise ValueError("工单号必须是数字") - if not short_title: - raise ValueError("标题不能为空") - - target = TASK_DIR / f"{issue_number}-{short_title}.md" - if target.exists(): - raise FileExistsError(f"文件已存在:{target}") - - content = TEMPLATE.read_text(encoding="utf-8") - content = content.replace("<工单号>", issue_number, 1) +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) - target.write_text(content, encoding="utf-8") - return target + content = content.replace("<链接>", issue_url, 1) + return content.replace("<页面名>", page_name, 1) -def main() -> None: - parser = argparse.ArgumentParser(description="创建 docs/task 任务归档草稿") +def main() -> int: + parser = argparse.ArgumentParser( + description="在 Gitea Wiki 创建任务归档并导出 docs/task 镜像" + ) 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() - target = create_archive(args.issue_number, args.title) - print(f"已创建:{target.relative_to(ROOT)}") + 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}" + local_path = f"docs/task/{args.issue_number}-{short_title}.md" + mapping = Mapping(page=page_name, path=local_path) + if any( + item.page == mapping.page or item.path == mapping.path + for item in config.mappings + ): + raise WikiDocsError(f"任务归档已经登记:{page_name}") + + client = WikiClient(config) + 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} 归档草稿", + ) + append_mapping(config, mapping) + updated_config = load_config(config.path) + messages = sync_all(updated_config, client) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + + print(f"已创建 Wiki:{page.html_url}") + for message in messages: + print(message) + print(f"已登记镜像:{local_path}") + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/sync_wiki_docs.py b/scripts/sync_wiki_docs.py new file mode 100644 index 0000000..e44b9d6 --- /dev/null +++ b/scripts/sync_wiki_docs.py @@ -0,0 +1,33 @@ +"""从 Gitea Wiki 单向导出本地 docs 镜像。""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from wiki_docs import DEFAULT_CONFIG, WikiClient, WikiDocsError, load_config, sync_all + + +def main() -> int: + parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步 docs 镜像") + parser.add_argument( + "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" + ) + parser.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" + ) + args = parser.parse_args() + try: + config = load_config(Path(args.config).resolve()) + messages = sync_all(config, WikiClient(config), check=args.check) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + for message in messages: + print(message) + print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wiki_docs.py b/scripts/wiki_docs.py new file mode 100644 index 0000000..dc2a85b --- /dev/null +++ b/scripts/wiki_docs.py @@ -0,0 +1,394 @@ +"""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 = "" +MIRROR_END = "" +HEADER_PATTERN = re.compile( + rf"\A{re.escape(MIRROR_START)}\n(?P.*?)\n" + rf"{re.escape(MIRROR_END)}\n\n(?P.*)\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};不会自动删除或重命名本地镜像" + ) + 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 页面响应格式无效:{page_name}") + encoded_content = page.get("content_base64") + if not isinstance(encoded_content, str): + raise WikiDocsError(f"Wiki 页面没有 content_base64:{page_name}") + try: + text = base64.b64decode(encoded_content, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise WikiDocsError(f"Wiki 页面不是有效的 UTF-8 Markdown:{page_name}") from exc + last_commit = page.get("last_commit") + revision = last_commit.get("sha") if isinstance(last_commit, dict) else None + if not isinstance(revision, str) or not revision: + raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}") + title = page.get("title") + resolved_title = title if isinstance(title, str) and title else page_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]: + paths = [mapping.path for mapping in config.mappings] + 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 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) diff --git a/tests/test_wiki_docs.py b/tests/test_wiki_docs.py new file mode 100644 index 0000000..5fca242 --- /dev/null +++ b/tests/test_wiki_docs.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from new_task_archive import build_archive, safe_title # noqa: E402 +from wiki_docs import ( # noqa: E402 + Config, + Mapping, + WikiDocsError, + WikiPage, + dirty_mirror_paths, + load_config, + parse_mirror, + render_mirror, + 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"]) + + +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) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki-docs.json b/wiki-docs.json new file mode 100644 index 0000000..fbeb277 --- /dev/null +++ b/wiki-docs.json @@ -0,0 +1,24 @@ +{ + "schema_version": 1, + "gitea_url": "http://ilaer.eicp.net:8418", + "owner": "opc", + "repository": "dev_harness", + "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": "Task-Archive-Template", + "path": "docs/templates/task-archive.md" + } + ] +}