feat: 引入 Wiki 文档镜像流程 (#1)
This commit is contained in:
@@ -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 通过后才
|
||||
|
||||
<!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 -->
|
||||
|
||||
- 尚未配置。开始产品开发前必须填写项目档案,并删除本行。
|
||||
- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。
|
||||
- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。
|
||||
- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。
|
||||
- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。
|
||||
|
||||
@@ -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、私钥或生产数据写入代码、日志、工单和文档。
|
||||
|
||||
|
||||
@@ -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/` 只保存可审查的镜像。
|
||||
- 一个单元工单只解决一个可独立测试和回退的问题。
|
||||
- 实现提交与归档提交分开,便于审查与追溯。
|
||||
- 凭据、个人数据和生产数据不得进入代码、工单或归档。
|
||||
|
||||
+37
-19
@@ -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` 是只读镜像。
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 项目名称 | `<填写>` |
|
||||
| 一句话目标 | `<填写>` |
|
||||
| Gitea 地址 | `<例如 https://gitea.example.com>` |
|
||||
| 仓库 | `<owner/repository>` |
|
||||
| 项目名称 | 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` 必须通过。
|
||||
- 未执行或无法覆盖的验证必须记录到工单。
|
||||
|
||||
+54
-18
@@ -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. 讨论
|
||||
|
||||
用户描述需求或故障。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` 镜像 |
|
||||
|---|---:|---:|---:|
|
||||
| 讨论过程和临时方案 | 是 | 否 | 否 |
|
||||
| 实施进度和阻塞 | 是 | 否 | 否 |
|
||||
| 长期有效的最终方案 | 链接 | 是 | 镜像 |
|
||||
| 测试结果与未验证内容 | 是 | 任务归档 | 镜像 |
|
||||
| 提交哈希 | 是 | 任务归档 | 镜像 |
|
||||
| 与具体代码版本绑定的说明 | 可链接 | 提供入口 | 是 |
|
||||
|
||||
+43
-6
@@ -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):仓库、技术栈、命令和负责人等稳定信息。
|
||||
- [开发工作流](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 或镜像。
|
||||
|
||||
Vendored
+10
@@ -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
|
||||
- Gitea 工单:<链接>
|
||||
- Wiki 页面:<页面名>
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+73
-28
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user