feat: 引入 Wiki 文档镜像流程 (#1)

This commit is contained in:
QiuSW
2026-08-07 23:57:13 +08:00
parent 1337b5c894
commit b08a919fd7
13 changed files with 859 additions and 92 deletions
+14 -8
View File
@@ -1,12 +1,12 @@
# Agent 开发规则 # Agent 开发规则
本仓库采用 DevHarness 工作流:Gitea 工单是实施期间的事实来源,Git 是代码变更记录,`docs/task` 是完成后的最终归档。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。 本仓库采用 DevHarness 工作流:Gitea 工单是任务过程的事实来源,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 是代码与版本绑定资料的变更记录,`docs/` 只保存 Wiki 的只读镜像。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。
开始工作前先阅读 [项目档案](docs/00-project-profile.md) 和任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。 开始工作前先阅读 [项目档案](docs/00-project-profile.md) 和任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。
## 1. 永久规则 ## 1. 永久规则
- 不把密码、令牌、Cookie、私钥、个人数据或生产数据写入代码、日志、工单和文档。 - 不把密码、令牌、Cookie、私钥、个人数据或生产数据写入代码、日志、工单、Wiki 和文档。
- 不执行未经用户明确授权的发布、付款、删除数据、破坏性迁移或其他不可逆操作。 - 不执行未经用户明确授权的发布、付款、删除数据、破坏性迁移或其他不可逆操作。
- 保留用户已有和任务无关的工作区改动,不擅自重置、覆盖或混入提交。 - 保留用户已有和任务无关的工作区改动,不擅自重置、覆盖或混入提交。
- 发现需求与安全规则、已确认方案或现有数据冲突时,先停止实施并说明影响。 - 发现需求与安全规则、已确认方案或现有数据冲突时,先停止实施并说明影响。
@@ -36,6 +36,7 @@
5. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。 5. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。
6. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。 6. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。
7. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。 7. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。
8. 长期文档必须先修改 Wiki、读取确认,再运行 `python scripts/sync_wiki_docs.py` 导出本地镜像;不得直接编辑 `docs/` 后反向覆盖 Wiki。
Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。 Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。
@@ -61,6 +62,7 @@ Epic:完整产品目标和长期路线
- 变化影响 MVP 或 Epic 时,同时更新父工单。 - 变化影响 MVP 或 Epic 时,同时更新父工单。
- 会改变用户已确认结果的变化,更新工单后必须再次等待用户确认。 - 会改变用户已确认结果的变化,更新工单后必须再次等待用户确认。
- 阻塞、失败方案和新发现根因不能只留在聊天或代码注释中。 - 阻塞、失败方案和新发现根因不能只留在聊天或代码注释中。
- Wiki 页面删除、重命名或事实源边界变化时,必须先更新工单并等待用户确认;同步工具不得自动传播删除或重命名。
- 工单状态应使用:待确认、待实施、进行中、阻塞、待验收、已完成。 - 工单状态应使用:待确认、待实施、进行中、阻塞、待验收、已完成。
## 6. Git 与验证 ## 6. Git 与验证
@@ -76,10 +78,11 @@ Epic:完整产品目标和长期路线
1. 实现完成后逐项检查验收标准,并提交代码。 1. 实现完成后逐项检查验收标准,并提交代码。
2. 更新单元工单:最终方案、方案差异、测试结果、提交哈希和遗留问题。 2. 更新单元工单:最终方案、方案差异、测试结果、提交哈希和遗留问题。
3. 工单保持“待验收”,用户没有明确验收通过前不得关闭。 3. 工单保持“待验收”,用户没有明确验收通过前不得关闭。
4. 按 `docs/templates/task-archive.md` 创建 `docs/task/<编号>-<短标题>.md`。 4. 运行 `python scripts/new_task_archive.py <编号> "<短标题>"`,先在 Wiki 创建任务归档,再登记映射并导出 `docs/task/<编号>-<短标题>.md` 镜像。
5. 归档文档单独提交,例如:`docs: 归档任务 #123`。 5. 读取确认 Wiki 页面,运行 `python scripts/sync_wiki_docs.py --check` 校验镜像。
6. 把归档路径和提交哈希回写工单。 6. 归档镜像单独提交,例如:`docs: 归档任务 #123`。
7. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。 7. 把 Wiki 页面、revision、镜像路径和提交哈希回写工单。
8. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。
MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。 MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。
@@ -89,7 +92,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- 类和函数保持单一职责,名称表达业务含义。 - 类和函数保持单一职责,名称表达业务含义。
- 注释解释原因、边界和风险,不逐行翻译代码。 - 注释解释原因、边界和风险,不逐行翻译代码。
- 错误必须可定位,不静默吞掉失败。 - 错误必须可定位,不静默吞掉失败。
- 文档先写结论和用途,再写步骤;示例命令应可直接复制。 - Wiki 文档先写结论和用途,再写步骤;示例命令应可直接复制,本地 `docs/` 由同步工具生成。
- 面向初级维护者说明从哪里开始读、怎样运行和怎样验证。 - 面向初级维护者说明从哪里开始读、怎样运行和怎样验证。
## 9. 引导提交例外 ## 9. 引导提交例外
@@ -102,4 +105,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
<!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 --> <!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 -->
- 尚未配置。开始产品开发前必须填写项目档案,并删除本行。 - 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。
- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。
- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。
- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。
+3 -2
View File
@@ -5,7 +5,7 @@
Claude Code 开始任何工作前,必须按顺序阅读: Claude Code 开始任何工作前,必须按顺序阅读:
1. 根目录的 `AGENTS.md`; 1. 根目录的 `AGENTS.md`;
2. `docs/00-project-profile.md`; 2. Gitea Wiki 的项目档案;Wiki 不可用时读取 `docs/00-project-profile.md` 镜像并明确其 revision;
3. 任务涉及目录中更具体的 `AGENTS.md`; 3. 任务涉及目录中更具体的 `AGENTS.md`;
4. 当前 Gitea 工单及其父级 MVP、Epic 工单。 4. 当前 Gitea 工单及其父级 MVP、Epic 工单。
@@ -13,7 +13,8 @@ Claude Code 开始任何工作前,必须按顺序阅读:
- 方案经用户确认并建立单元任务工单后,才能修改产品代码。 - 方案经用户确认并建立单元任务工单后,才能修改产品代码。
- 只修改当前工单范围内的文件,保留用户已有和无关的改动。 - 只修改当前工单范围内的文件,保留用户已有和无关的改动。
- 实现、测试、Git 提交、待验收、任务归档和关闭工单的顺序不得跳过。 - 实现、测试、Git 提交、待验收、Wiki 任务归档、导出本地镜像和关闭工单的顺序不得跳过。
- 长期文档先修改 Wiki,再导出 `docs/`;不得直接编辑镜像作为最终结果。
- 未经用户明确验收,不得关闭工单。 - 未经用户明确验收,不得关闭工单。
- 不得把密码、令牌、Cookie、私钥或生产数据写入代码、日志、工单和文档。 - 不得把密码、令牌、Cookie、私钥或生产数据写入代码、日志、工单和文档。
+14 -11
View File
@@ -1,6 +1,6 @@
# DevHarness # DevHarness
DevHarness 是一个以 Gitea 工单为事实来源、以 Git 提交为变更记录、由人负责确认和验收的 AI 辅助开发模板。 DevHarness 是一个以 Gitea 工单管理任务过程、以 Gitea Wiki 管理长期文档、以 Git 提交记录代码变更、由人负责确认和验收的 AI 辅助开发模板。
它约束的是开发过程,不限制项目使用 Python、Go、JavaScript 或其他技术栈。 它约束的是开发过程,不限制项目使用 Python、Go、JavaScript 或其他技术栈。
@@ -14,18 +14,19 @@ DevHarness 是一个以 Gitea 工单为事实来源、以 Git 提交为变更记
-> Agent 实现并测试 -> Agent 实现并测试
-> 提交代码并更新工单 -> 提交代码并更新工单
-> 人工验收 -> 人工验收
-> 归档 docs/task -> 先归档 Wiki,再导出 docs/task 镜像
-> 关闭工单并更新父工单 -> 关闭工单并更新父工单
``` ```
## 快速开始 ## 快速开始
1. 复制或克隆本仓库,并修改仓库名称。 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`。 3. 把项目不可违反的安全规则写入根目录或子项目的 `AGENTS.md`。
4. 创建 Gitea 远端仓库并推送当前引导提交。 4. 创建 Gitea 远端仓库并推送当前引导提交。
5. 使用 `.gitea/issue_template/` 中的模板创建第一个 Epic、MVP 和单元任务。 5. 配置 `wiki-docs.json` 的页面映射;读取 Wiki 可匿名访问或使用 `GITEA_TOKEN`,写 Wiki 必须通过环境变量提供令牌。
6. 开始产品代码前运行: 6. 使用 `.gitea/issue_template/` 中的模板创建第一个 Epic、MVP 和单元任务。
7. 开始产品代码前运行:
```powershell ```powershell
python scripts/check_harness.py --strict python scripts/check_harness.py --strict
@@ -39,18 +40,20 @@ DevHarness 是一个以 Gitea 工单为事实来源、以 Git 提交为变更记
AGENTS.md Agent 的通用工作规则 AGENTS.md Agent 的通用工作规则
CLAUDE.md Claude Code 的规则入口 CLAUDE.md Claude Code 的规则入口
.gitea/issue_template/ Epic、MVP、单元任务工单模板 .gitea/issue_template/ Epic、MVP、单元任务工单模板
docs/00-project-profile.md 每个项目需要填写的档案 docs/00-project-profile.md Wiki 项目档案的只读镜像
docs/01-workflow.md 人和 Agent 都能阅读的流程说明 docs/01-workflow.md Wiki 开发工作流的只读镜像
docs/templates/task-archive.md 完成后的本地归档模板 docs/templates/task-archive.md Wiki 任务归档模板的只读镜像
docs/task/ 已完成任务的最终记录 docs/task/ Wiki 任务归档页的只读镜像
wiki-docs.json Wiki 页面到本地镜像的显式映射
scripts/check_harness.py 模板和归档的最小自检 scripts/check_harness.py 模板和归档的最小自检
scripts/new_task_archive.py 创建任务归档文件 scripts/sync_wiki_docs.py 单向导出或检查 Wiki 镜像
scripts/new_task_archive.py 先创建 Wiki 任务归档,再导出镜像
``` ```
## 设计原则 ## 设计原则
- 人决定目标、范围和验收结果,Agent 负责检查、实现和验证。 - 人决定目标、范围和验收结果,Agent 负责检查、实现和验证。
- 工单记录实施过程,`docs/task` 只保存完成后的最终事实。 - 工单记录实施过程,Wiki 保存长期有效的最终事实,`docs/` 只保存可审查的镜像。
- 一个单元工单只解决一个可独立测试和回退的问题。 - 一个单元工单只解决一个可独立测试和回退的问题。
- 实现提交与归档提交分开,便于审查与追溯。 - 实现提交与归档提交分开,便于审查与追溯。
- 凭据、个人数据和生产数据不得进入代码、工单或归档。 - 凭据、个人数据和生产数据不得进入代码、工单或归档。
+37 -19
View File
@@ -1,23 +1,32 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 项目档案 # 项目档案
复制模板后先填写本页。这里保存不经常变化、所有维护者都需要知道的信息。 本页记录不经常变化、所有维护者都需要知道的信息。它是项目档案的事实来源;仓库内 `docs/00-project-profile.md` 是只读镜像。
## 基本信息 ## 基本信息
| 项目 | 内容 | | 项目 | 内容 |
|---|---| |---|---|
| 项目名称 | `<填写>` | | 项目名称 | DevHarness |
| 一句话目标 | `<填写>` | | 一句话目标 | 提供以 Gitea 工单、Wiki 和 Git 为事实来源的 AI 辅助开发工作流模板 |
| Gitea 地址 | `<例如 https://gitea.example.com>` | | Gitea 地址 | http://ilaer.eicp.net:8418 |
| 仓库 | `<owner/repository>` | | 仓库 | `opc/dev_harness` |
| 默认分支 | `main` | | 默认分支 | `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` 必须通过。
- 未执行或无法覆盖的验证必须记录到工单。
+54 -18
View File
@@ -1,14 +1,29 @@
<!-- gitea-wiki-mirror:start -->
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-wiki-mirror:end -->
# 开发工作流 # 开发工作流
## 事实来源边界
- Gitea 工单记录任务状态、讨论、阻塞、方案变化、验证和验收。
- Gitea Wiki 保存架构说明、开发规范、操作手册和完成后的任务归档。
- Git 保存源码、与特定代码版本强绑定的文档,以及 Wiki 的本地镜像。
- 本地 `docs/` 仅供浏览和审查,不是长期文档编辑入口。
## 一次任务怎样完成 ## 一次任务怎样完成
### 1. 讨论 ### 1. 讨论
用户描述需求或故障。Agent 先检查现状,再给出目标、非目标、方案、风险、回退和验证方法。存在不同实现方向时,说明取舍,让用户确认。 用户描述需求或故障。Agent 先检查现状,再给出目标、非目标、方案、风险、回退和验证方法。存在不同实现方向时,说明取舍并等待用户确认。
### 2. 建单 ### 2. 建单
方案确认后,使用 `.gitea/issue_template/task.md` 创建单元任务工单。没有工单号之前不修改产品代码。 方案确认后,使用 `.gitea/issue_template/task.md` 创建单元任务工单。没有工单号之前不修改产品代码或正式文档。
新产品或较大版本先建立 Epic,再建立 MVP: 新产品或较大版本先建立 Epic,再建立 MVP:
@@ -24,29 +39,49 @@
### 3. 实施 ### 3. 实施
Agent 检查工作区,只修改工单范围内的文件。发现新问题时先记录到工单;如果它不影响当前验收,另建工单,不扩大当前任务。 Agent 检查分支和工作区,只修改工单范围内的文件。发现新问题时先记录到工单;如果不影响当前验收,则另建工单,不扩大当前任务。
重要进度应及时写回工单: 重要进度及时写回工单:
- 已确认的根因; - 已确认的根因;
- 方案或范围变化; - 方案或范围变化;
- 测试结果; - 测试结果;
- 阻塞和未验证内容; - 阻塞和未验证内容;
- Git 提交哈希。 - Git 提交哈希;
- 相关 Wiki 页面及 revision。
长期文档遵循唯一顺序:
```text
修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像
```
不得先编辑 `docs/` 再反向覆盖 Wiki。
### 4. 待验收 ### 4. 待验收
实现和测试完成后,Agent 提交代码并将工单更新为待验收。用户验收前工单保持开启。 实现和测试完成后,Agent 提交实现代码并将工单更新为“待验收”。用户验收前工单保持开启。
### 5. 归档和关闭 ### 5. 归档和关闭
使用以下命令创建归档草稿: 使用以下命令在 Wiki 创建任务归档页、登记显式映射并导出本地镜像:
```powershell ```powershell
python scripts/new_task_archive.py 123 "修复登录超时" 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` 镜像 |
|---|---:|---:| |---|---:|---:|---:|
| 讨论过程和临时方案 | 是 | 否 | | 讨论过程和临时方案 | 是 | 否 | 否 |
| 实施进度和阻塞 | 是 | 否 | | 实施进度和阻塞 | 是 | 否 | 否 |
| 最终实现方案 | 是 | 是 | | 长期有效的最终方案 | 链接 | 是 | 镜像 |
| 测试结果与未验证内容 | 是 | 是 | | 测试结果与未验证内容 | 是 | 任务归档 | 镜像 |
| 提交哈希 | 是 | 是 | | 提交哈希 | 是 | 任务归档 | 镜像 |
| 长期有效的最终结论 | 可链接 | 是 | | 与具体代码版本绑定的说明 | 可链接 | 提供入口 | 是 |
+43 -6
View File
@@ -1,8 +1,45 @@
# 文档索引 <!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
- [项目档案](00-project-profile.md):仓库、技术栈、命令和负责人等稳定信息。 # DevHarness 文档中心
- [开发工作流](01-workflow.md):从需求讨论到工单关闭的完整顺序。
- [任务归档模板](templates/task-archive.md):任务完成后的固定格式。
- `task/`:已经完成并与代码版本对应的任务记录。
临时进度、方案讨论和待办事项写入 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 或镜像。
+10
View File
@@ -1,3 +1,11 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# <工单号> <标题> # <工单号> <标题>
- 类型:需求 / 缺陷 / 重构 - 类型:需求 / 缺陷 / 重构
@@ -6,6 +14,8 @@
- 状态:待验收 / 已完成 - 状态:待验收 / 已完成
- 日期:YYYY-MM-DD - 日期:YYYY-MM-DD
- Gitea 工单:<链接> - Gitea 工单:<链接>
- Wiki 页面:<页面名>
- Wiki revision:见本地镜像头
## 背景与目标 ## 背景与目标
+43
View File
@@ -6,6 +6,8 @@ import argparse
import re import re
from pathlib import Path from pathlib import Path
from wiki_docs import WikiDocsError, load_config, parse_mirror
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
REQUIRED_FILES = ( REQUIRED_FILES = (
@@ -14,6 +16,9 @@ REQUIRED_FILES = (
"docs/00-project-profile.md", "docs/00-project-profile.md",
"docs/01-workflow.md", "docs/01-workflow.md",
"docs/templates/task-archive.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/epic.md",
".gitea/issue_template/mvp.md", ".gitea/issue_template/mvp.md",
".gitea/issue_template/task.md", ".gitea/issue_template/task.md",
@@ -56,6 +61,43 @@ def check_archives(errors: list[str]) -> None:
errors.append(f"{path.name} 没有记录未验证部分") 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: def main() -> int:
parser = argparse.ArgumentParser(description="检查 DevHarness 项目结构") parser = argparse.ArgumentParser(description="检查 DevHarness 项目结构")
parser.add_argument( parser.add_argument(
@@ -69,6 +111,7 @@ def main() -> int:
warnings: list[str] = [] warnings: list[str] = []
check_required_files(errors) check_required_files(errors)
check_project_profile(errors, warnings, args.strict) check_project_profile(errors, warnings, args.strict)
check_wiki_mirrors(errors)
check_archives(errors) check_archives(errors)
for warning in warnings: for warning in warnings:
+73 -28
View File
@@ -1,4 +1,4 @@
"""根据模板创建任务归档草稿。""" """先在 Gitea Wiki 创建任务归档,再登记并导出本地镜像。"""
from __future__ import annotations from __future__ import annotations
@@ -7,50 +7,95 @@ import re
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from wiki_docs import (
ROOT = Path(__file__).resolve().parents[1] DEFAULT_CONFIG,
TEMPLATE = ROOT / "docs" / "templates" / "task-archive.md" Mapping,
TASK_DIR = ROOT / "docs" / "task" WikiClient,
WikiDocsError,
append_mapping,
load_config,
sync_all,
)
def safe_title(title: str) -> str: def safe_title(title: str) -> str:
"""把标题转换为适合文件名的短文本。""" """把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。"""
cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip()) cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip())
cleaned = re.sub(r"\s+", "-", cleaned) cleaned = re.sub(r"\s+", "-", cleaned)
cleaned = re.sub(r"-+", "-", cleaned)
return cleaned.strip(".-") return cleaned.strip(".-")
def create_archive(issue_number: str, title: str) -> Path: def build_archive(
"""创建归档草稿;目标文件存在时拒绝覆盖。""" template: str,
issue_number: str,
short_title = safe_title(title) title: str,
if not issue_number.isdigit(): page_name: str,
raise ValueError("工单号必须是数字") issue_url: str,
if not short_title: ) -> str:
raise ValueError("标题不能为空") content = template.replace("<工单号>", issue_number, 1)
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)
content = content.replace("<标题>", title.strip(), 1) content = content.replace("<标题>", title.strip(), 1)
content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1) content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1)
target.write_text(content, encoding="utf-8") content = content.replace("<链接>", issue_url, 1)
return target return content.replace("<页面名>", page_name, 1)
def main() -> None: def main() -> int:
parser = argparse.ArgumentParser(description="创建 docs/task 任务归档草稿") parser = argparse.ArgumentParser(
description="在 Gitea Wiki 创建任务归档并导出 docs/task 镜像"
)
parser.add_argument("issue_number", help="Gitea 工单号,例如 123") parser.add_argument("issue_number", help="Gitea 工单号,例如 123")
parser.add_argument("title", help="简短任务标题") parser.add_argument("title", help="简短任务标题")
parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置")
args = parser.parse_args() args = parser.parse_args()
target = create_archive(args.issue_number, args.title) short_title = safe_title(args.title)
print(f"已创建:{target.relative_to(ROOT)}") 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__": if __name__ == "__main__":
main() raise SystemExit(main())
+33
View File
@@ -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())
+394
View File
@@ -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 = "<!-- 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};不会自动删除或重命名本地镜像"
)
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)
+117
View File
@@ -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()
+24
View File
@@ -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"
}
]
}