# PR 描述书写规范

> 触发：所有提交到本仓库的 PR（人写 / AI 生成皆适用）。
> 关联模板：
> - `.gitea/PULL_REQUEST_TEMPLATE.md` —— Feature PR（→ develop）
> - `.gitea/PULL_REQUEST_TEMPLATE/promotion.md` —— 晋级 PR（develop → staging / staging → production），见下「晋级 PR（Promotion）规范」段
>
> 关联 skill：`.agents/skills/code-review/`（评审 skill）。

## 为什么有这个规范

PR 描述要同时服务**两个目的**：

1. **当下 reviewer 决策**："30 秒能不能判断该不该 merge？"——做了什么、为什么、怎么验证、风险。
2. **未来回顾 / archaeology**："半年后做 retro / bisect 找回归 / 写月度复盘 / 看月度改进时，能不能只看 PR body 就还原当时实情？"——权衡、被拒绝的备选、量化指标、衍生工单、隐藏耦合。

工程实践上"未来回顾"是更稀缺的——AI 高频 PR 下 reviewer 时间不变、commit 历史 squash 后只剩一行，**PR body 是唯一长期可读的载体**。多数模板只优化目的 1。本规范的差异化在于：用条件段把"未来回顾"信息也固定在 body 里。

两类历史症状催生本规范：**描述堆"演进过程"而非"最终状态"**（reviewer 看到的只关心合并后是什么），以及**漏写 `Closes #N` 关键字导致 issue 变孤儿**（一次发现 5 个 PR 已解决但工单未关）。

## 评审检查清单

reviewer / AI Review 拿到 PR 描述时按此清单逐项核对。任一项不满足 → 让作者修，或在 review 评论里指出。

- [ ] 摘要一句话讲清做了什么 + 为什么
- [ ] **解决 issue 的 PR 含 `Closes #N`**（每个 issue 独立一行，禁止逗号合并）
- [ ] 「为什么现在做」写了触发场景（不是"项目需要"这种空话）
- [ ] 「风险与回滚」写了影响面 + 回滚路径
- [ ] 如何验证每项有 `[x]/[ ]` 勾选状态，不是"已通过"
- [ ] **PR push 后又加了 commit → title + body 都已 PATCH 反映最终状态**（无"追加 commit"/"补丁式 addendum"等演进史段；title 还能涵盖 PR 当前所有 commit 主题）
- [ ] 「破坏性变更」段始终保留（哪怕只写一个"否"），方便未来 changelog 检索
- [ ] 触碰契约面 / 架构 / 高风险路径 → 含「权衡与备选方案」段
- [ ] 性能 / 体积 / CI 时长 / 覆盖率类 PR → 含「度量指标」段（before/after）
- [ ] 涉及契约面的 PR 含「契约面检查」段
- [ ] 触碰高风险路径的 PR 含「高风险路径与违规说明」段 + 说明
- [ ] 「后续跟进」段填了（无衍生写"无"）
- [ ] 无"我们"/"本 PR"/"AI" 等填充主语（example 代码块内除外）
- [ ] 踩坑沉淀引 `.learnings/` 路径，未把全文复制进 body
- [ ] 未写演进过程（除非演进本身就是讨论重点）
- [ ] 文件清单按模块分组 + `[basename](path)` link，未"完整路径并排列"（详见「文件清单展示规范」段）

## 六条元规则

> **铁律**：解决 issue 的 PR 必须写 `Closes #N`，每个 issue 独立一行。漏写 = issue 变孤儿。

| # | 规则 | 反例 | 正例 |
|---|---|---|---|
| **1** | 写"最终状态"，不写"演进过程" | "先尝试 A，发现不行；改用 B，又踩坑 C；最终用 D" | "用 D：根因 Y → 解法 Z" |
| **2** | 解决 issue **必填** `Closes #N` | （遗漏，issue 变孤儿） | `Closes #276` 写在「关联」段 |
| **3** | 摘要一句话讲完 | 用三段铺垫才说清做了什么 | "修 X bug：根因 Y，解法 Z" |
| **4** | 踩坑沉淀放 `.learnings/`，body 只引路径 | body 复制整个 ERR 全文 | "详见 `.learnings/ERRORS/ERR-20260510-001.md`" |
| **5** | 防过度叙事：每段砍 20% | 堆"我们"/"本 PR"/"AI"/"在这里"等填充词 | 主语省略，动词直接，表格优于多段文字 |
| **6** | **PR 推新 commit 后必须 PATCH title + body 反映最终状态** | 在 body 末尾堆"## 追加 commit：..."段（演进史，违反规则 1）；title 还停留在第 1 个 commit 的描述 | 重写整个 body 按"合并后是什么"视角；改 `## 主要组成`/`## 后续跟进` 等段反映新增内容；**如果 PR 内容已超出 title 涵盖范围，同步 PATCH title** |

## 模板段落判定（v2 两层设计）

按 `.gitea/PULL_REQUEST_TEMPLATE.md` 的段顺序。**上半部分核心叙事 5 段永远必填**；**下半部分回顾段按场景填，不填整段删**：

### 上半部分（核心叙事，永远必填 / 主要组成条件填）

| 段落 | 写什么 | 何时填 |
|---|---|---|
| `## 摘要` | 一句话讲完做了什么 + 为什么 | 永远必填 |
| `## 主要组成` | 多文件 / 多 topic / 多迭代 PR 用表格列要点：文件 + 改动 / 主题 + 文件 | **条件填**：单文件 hotfix / 文档可删整段 |
| `## 为什么现在做` | 触发场景（bug 复现 / 性能瓶颈 / 外部约束 / 技术债到期 / 复盘衍生）。给未来：半年后看到这 PR 能知道当时为什么 | 永远必填 |
| `## 如何验证` | checkbox 列表 + 实际操作。每项 `[x]/[ ]` 清晰，禁止"已通过"四个字 | 永远必填 |
| `## 风险与回滚` | 影响面 + 回滚预案。一句话也行 | 永远必填 |
| `## 破坏性变更` | **始终保留**：N 时写"否"，Y 时列依赖方 + 迁移路径 + 兼容窗口。便于未来 changelog / bisect 检索 | 永远必填 |

### 下半部分（条件填，不填删整段）

| 段落 | 何时必填 |
|---|---|
| `## 权衡与备选方案` | 触碰契约面 / 架构 / 高风险路径时必填——给未来：类似决策不重复推理 |
| `## 度量指标` | 性能 / 体积 / CI 时长 / 覆盖率 / 错误率类 PR 必填——before/after 量化 |
| `## 契约面检查` | 涉及契约面时必填（API/数据/状态/权限/UI/事件，判定见 [contract-check skill](../../.agents/skills/contract-check/SKILL.md)） |
| `## 高风险路径与违规说明` | 触碰高风险路径时必填（清单见 [CLAUDE.md「PR 拆分准则」](../../CLAUDE.md)，单一事实源） |
| `## 后续跟进` | **永远填**（无衍生写"无"）：本 PR 留下的 TODO / 衍生 issue / 已知限制 / out-of-scope |
| `## 关联` | 解决 issue 时必填 `Closes #N` + 关联 PR / 文档 / `.learnings` |

## 晋级 PR（Promotion）规范

> 上面所有内容是为 **Feature PR**（→ develop）设计的。**晋级 PR**（`develop → staging` / `staging → production`）服务的是不同的 audience，需要不同的模板。

### Feature PR vs Promotion PR

| 维度 | Feature PR（→ develop） | Promotion PR（develop → staging / staging → production）|
|---|---|---|
| audience | 代码 reviewer | QA / 业务方 / 部署负责人 / 发布会议 |
| 关心什么 | 代码质量、设计、tests | 这批东西要测什么、出问题怎么办、对生产有什么影响 |
| 决策点 | 这代码能合 develop 吗 | 这批改动能上 UAT 让真人测了吗 / 能上生产了吗 |
| 类型 | 单 topic 内聚 | 多 topic 累积（一周 10-30 commits 跨多个原 PR）|
| 标题 | 一行讲清功能 | "晋级 develop→staging YYYY-WNN" 或 "release v2026-WNN" |
| body 重点 | 摘要 / 验证 / 风险 / 契约面 | **晋级范围分类** / **UAT 必测清单** / 变更汇总 / 风险热点 / 回滚 |

**反例**（PR #325 的早期版本）：30 个 commits 跨多 topic 的 promotion PR，但用 Feature PR 模板、标题只写了 30 个 commits 中 1 个的功能、body 几乎全是模板占位——结果 QA 看到 PR 标题以为只要测 audit 详情页，漏测其他 29 个 commits 涉及的功能。

### Promotion PR 必填段落

按 `.gitea/PULL_REQUEST_TEMPLATE/promotion.md` 的段顺序：

| 段落 | 写什么 | 何时必填 |
|---|---|---|
| `## 本次晋级范围` | 一行：base ← head / 周期 / commit 数 / PR 数 | 永远必填 |
| `## 按类型分类` | 按 conventional commit 类型分组列入选的 PR | 永远必填 |
| `## UAT 必测清单` | 给 QA 的可执行清单：新功能 / i18n 双语 / 回归重点 / 外部集成 | 永远必填 |
| `## 破坏性变更汇总` | 跨所有 commit 汇总 N/Y，与 Feature PR 同段语义 | 永远必填 |
| `## Schema / Env / Config 变更汇总` | 跨所有 commit 汇总迁移 / env / 部署侧变更 | 永远必填 |
| `## 风险热点` | 表格：风险等级（🔴/🟡/🟢）+ 内容 + 来源 PR | 永远必填 |
| `## 回滚策略` | 三层场景：UAT 单功能 / UAT 整体 / 生产后发现 | 永远必填 |
| `## 已知遗留 / 后续跟进` | 本批已知未解决的问题 / 下一批要带的事 | 永远必填，写"无"也行 |
| `## 关联` | `Closes #N` 关联本批关闭的 issue | 解决 issue 时必填 |

注意：Feature PR 模板里的「摘要 / 为什么现在做 / 如何验证 / 权衡与备选 / 度量指标」**不在 Promotion PR 里出现**——这些信息在原 Feature PR 里已经有了，晋级 PR 不要复述。

### 工具支持

**自动生成 80% 草稿**：在仓库根目录跑：

```bash
python3 scripts/ops/promotion-pr-body.py --base staging --head develop
```

脚本会：

- 拉取 `base..head` 的 commit log
- 解析 conventional commit 类型，自动分类 Features / Fixes / Refactor / Chore
- 识别 merge commit 里的 PR 编号
- 检测 `prisma/migrations/**` 新增迁移
- 检测 `.env.example` 变更
- 标记触碰高风险路径的 commit（建议风险等级 🔴）
- 输出符合 promotion.md 模板的 markdown

剩余 20% 必须人工补：

- **UAT 必测清单**的"回归重点"和"外部集成"段——根据本批改动具体业务模块决定
- **风险热点表**的人工判断——脚本只能建议，不能替代人脑（如某 refactor PR 看起来低风险但触碰热路径就要标 🔴）
- **回滚策略**里的"上一个 promotion 标签"——需要人工查
- **已知遗留 / 后续跟进** —— 脚本不知道

### 创建 Promotion PR 的标准流程

1. 在仓库根目录跑脚本生成草稿 → 重定向到临时文件
2. 人工补全 20%
3. 用 Gitea API 创建 PR：`base=staging head=develop`，body 用补全后的草稿
4. 在 PR 描述顶部把 staging 上一个 promotion 标签也贴上（方便回滚定位）
5. PR 一旦创建好，**通知 QA + 列出"本批 UAT 必测清单"作为 issue 评论**——让 QA 知道扫描范围

### Promotion PR 的合并规则

- **合并到 staging**：CI 必须全绿；不必走双人 approve；**但仍受 CLAUDE.md 铁律约束——PR 作者不能合自己的 PR**，需另一位团队成员或 AIBot 合并
- **合并到 production**：必须 staging 上的 UAT 已完成 + 业务方 ✅；同样禁止作者自合；建议在合并前打 tag `v<日期>-staging-passed`

### 反模式（别这样做）

| 反模式 | 为什么不行 |
|---|---|
| Promotion PR 用 Feature PR 模板 | audience 错配，QA / 业务方拿不到他们需要的信息 |
| 标题只写其中 1 个 commit 的功能 | 屏蔽其他 commits，QA 漏测 |
| body 复制粘贴所有 commit message | 不分类不分级，等于没写 |
| 不写"上一个 promotion 标签" | UAT 崩溃时找不到回滚锚点 |
| 不写 UAT 必测清单依赖 QA 自己看 diff | QA 看不懂 diff，且每次都要重新理解 |
| 一次 promotion 包含太多周期累积（如 3 周 80 commits）| 回滚粒度太粗，UAT 周期变长，建议**至少每周一批** |

## Closes 关键字

Gitea 与 GitHub 一致，PR body 或 commit message 出现以下关键字 + `#编号`，PR merge 后自动关 issue：`Closes` / `Close` / `Fixes` / `Fix` / `Resolves` / `Resolve` / `关闭` / `修复`。

| 场景 | 写法 | 效果 |
|---|---|---|
| 完全解决 1 个 issue | `Closes #276` 写在「关联」段 | merge 后自动 close issue |
| 一次解决多个 issue | 每行一个：`- Closes #249` / `- Closes #250` | 全部自动 close |
| 一次解决多个 issue（错） | `Closes #249, #250, #251` | **只关第一个**，其余变普通文本 |
| 部分解决 | `Refs #260（本 PR 是止血方案，根治见 issue 子项 B）` | issue 仍 open，留人工决定 |
| 在 commit message 写 | 末尾加 `Closes #276` | 也生效，但 squash merge 会改写 commit message，PR body 显式写最稳 |

## 「未来回顾」三段怎么填

新增的三段（权衡、度量、后续跟进）是 v2 的核心增量。填写要点：

### 权衡与备选方案

考虑过的备选 + 没选的原因 + 接受的代价。半年后再遇到类似决策，未来读者直接看这段就知道"当时为什么不选 B"，不用重新走一遍推理。

例：

```markdown
## 权衡与备选方案

- 考虑过：用 Redis pub/sub 替代 Temporal signal
- 没选的原因：现有 Temporal worker 已部署，新加 Redis pub/sub 等于双跑两套异步基础设施，运维负担超过单跑 Temporal 的延迟成本
- 接受的代价：Temporal signal 延迟 ~50ms（pub/sub 是 ~5ms），但对本场景（异步通知，非实时）够用
```

### 度量指标

性能 / 体积 / CI 时长 / 错误率 / 覆盖率等量化值。before/after 直接贴数字。

例：

```markdown
## 度量指标

- before：backend-integration job p95 = 380s，OOM 率 12%
- after：backend-integration job p95 = 145s，OOM 率 0%
```

无量化指标的小 PR 删整段。

### 后续跟进

本 PR 衍生的 TODO / issue / out-of-scope，用 issue 链接，不写"以后再说"。强制思考一遍即使是"无"。

例：

```markdown
## 后续跟进

- #305 weekly-retro F6-F10（本 PR 拆出的 follow-up）
- 已知限制：dedicated-runner-1 装 docker 后才能迁移 backend-integration，等 ops 排期
```

## 多 commit PR 的职责分工

| 载体 | 职责 |
|---|---|
| **commit message** | 微观决策。这一刻为什么这么改？相对上个 commit 改了什么？只服务于本分支开发者 + bisect |
| **PR body** | 宏观成品视图。reviewer 看合并后的最终状态是什么、为什么 merge。squash merge 后 commit history 只剩一行，**所有 reviewer 要看的信息必须在 PR body 里** |

## 防过度叙事的具体做法

AI 生成 PR 描述容易堆细节、抓不住要点。反模式 → 改法：

| 反模式 | 改成 |
|---|---|
| 用"我们"/"本 PR"/"AI"作主语 | 主语省略，动词直接（"加 X" 而不是"本 PR 加 X"） |
| 多段叙述每步骤 | 表格列要点 |
| 把 commit message 全文 copy 进 body | body 只写最终成品视图，过程在 commit 里看 |
| 把 `.learnings/` 全文复制进 body | 引路径，不复制 |
| 罗列"我做了 X、Y、Z"流水账 | 按因果组织："根因 → 解法 → 验证" |
| 「如何验证」写 "测试已通过" | 列具体步骤 + checkbox 状态 |

**校验口诀**：写完后逐段问"如果删掉这一段，reviewer 决策 + 未来 retro 都不受影响吗？"——都不影响就删。

## 外部配置如何在 PR 描述里体现

"已生效但不进 git" 的配置（如 secret、bot 账号、外部凭据）：

- **放「风险与回滚」段**：写明依赖的外部配置 + 已配置位置 + 操作人 + 时间
- **不要放摘要**（不是改动内容）
- **不要放如何验证**（不是验证步骤）

例：

```markdown
## 风险与回滚

- 依赖 Gitea bot 账号 `ai-review-bot` token（已在 dedicated-runner-1 secrets 配，2026-05-09 ops 操作）
- Token 失效时 AI Review workflow 跑不起来，回滚：把 secrets 还原
```

## 反例 vs 正例

### 案例 A：演进过程 vs 最终状态

**反例**：

```
## 摘要

第一版尝试在 develop 分支放 `.pr-marker-275`，CI 通过路径名识别。
后来发现这种方式不利于历史追溯，改用 Gitea issue 当 marker。
又遇到 issue 状态机不支持自定义 label，最终改用 `weekly-retro` label 实现。
```

**正例**：

```
## 摘要

周复盘机制：每周六 cron 触发 → 生成 Gitea issue（label `weekly-retro`）+ 数据快照 →
团队在 issue 评论里选本周改进项 → 做完 close issue。

## 主要组成

| 组件 | 文件 | 职责 |
|---|---|---|
| Cron | [weekly-retro.yml](.gitea/workflows/weekly-retro.yml) | 周六 09:00 触发 |
| Issue 生成 | [scripts/ops/weekly-retro/](scripts/ops/weekly-retro/) | 拉数据 + 调 Gitea API |
```

### 案例 B：堆细节 vs 抓要点

**反例**：

```
## 摘要

我们今天通过仔细分析发现，agent-pool 的 sweep 脚本不能识别一种特殊情况。
具体是这样的：当 agent 进程退出后，由于 heartbeat 守护进程是 detached 的，
它会继续运行并持续更新 heartbeat_at 字段。所以即使 agent 已经不在了，
sweep 仍然认为 slot 是 alive 的。这就导致了 ...（再两段）
```

**正例**：

```
## 摘要

修 agent-pool 一个已发现的活体泄漏：5 个 slot 全部 `claimed`，sweep 回收 0，
但实际 slot 路径下 0 个 Claude 进程、5 个 task_branch 远端全部已删——池永久打满，
下次 claim 必撞 exit 3。

## 为什么现在做

2026-05-10 工单对账时活体发现，PR 集中合并后池打满阻断下次 claim。
之前一直靠手工 release 兜底，AI 高频合并下兜底成本不可控。

## 根因

heartbeat 守护用 nohup detached，跟 agent 进程解耦。AI 工作流常忘 release →
heartbeat 一直更新 → `ap_lock_is_stale` 永远 not-stale。
```

差异：正例把"现象 + 数据"放第一句、「为什么现在做」承接 trigger context、根因独立成段单刀直入。反例把"现象"和"原因"和"AI 心路历程"揉成一坨。

## 文件清单展示规范

**适用范围**：PR body（特别是「主要组成」段）、`.learnings/` 报告。
**不适用**：commit message（纯文本无 link 渲染）、chat 总结（场景太活，靠 AI 默认风格即可）。

### 三条规则

1. **同模块 ≥2 文件 → 按模块/主题分组**。先点出模块名（一行），再列文件。避免读者先扫一长串路径才拼得出"改了哪几块"。
2. **文件用 `[basename.ext](full/path)` markdown link**。basename 前置承担信息密度，路径折进点击区，Gitea / VSCode / 网页 review 均支持点击跳转。
3. **文件 >10 个 → `<details>` 折叠详细清单**。正文只保留模块名 + 一句话改动摘要，全量清单放折叠区。

### 反例 vs 正例

**反例**（完整路径并排列，扫读疲劳，模块归属藏在尾段）：

```markdown
- `backend/src/core/workflow/temporal/temporal.service.ts` 测试环境跳过 Temporal 连接
- `engines/approval/temporal/temporal.service.ts` 同步加判断
- `testing/scripts/run-backend-integration.sh` 加 NODE_OPTIONS=--max-old-space-size=4096
```

**正例**（按模块分组 + basename link）：

```markdown
- **temporal 服务**：[temporal.service.ts](backend/src/core/workflow/temporal/temporal.service.ts) + [temporal.service.ts](engines/approval/temporal/temporal.service.ts) — 测试环境跳过 Temporal 连接
- **集成测试脚本**：[run-backend-integration.sh](testing/scripts/run-backend-integration.sh) — 加 `NODE_OPTIONS=--max-old-space-size=4096` 治 jest OOM
```

差异：正例先点"temporal / scripts"两个模块，扫读 0.5 秒拼出改动版图；basename 直接告诉读者"是 service 还是 script"，全路径折进点击区。

### 文件 >10 的折叠示例

```markdown
## 主要组成

涉及 **temporal / runner / scripts** 三块，共 14 文件。核心改动：worker 注册路径下沉到 engine 包 + scripts 适配新路径。

<details><summary>详细文件清单</summary>

- **temporal**：[temporal.service.ts](backend/src/core/workflow/temporal/temporal.service.ts)、[worker.ts](backend/src/core/workflow/temporal/worker.ts)、...
- **runner**：[runner.ts](engines/approval/temporal/runner.ts)、...
- **scripts**：[run-backend-integration.sh](testing/scripts/run-backend-integration.sh)、...

</details>
```

## 模板/工具集成

- **模板文件**：
  - Feature PR：`.gitea/PULL_REQUEST_TEMPLATE.md`（v2 — 上下两层 + 全中文段标题）
  - Promotion PR：`.gitea/PULL_REQUEST_TEMPLATE/promotion.md`（晋级 develop → staging / staging → production）
- **Promotion PR 自动生成**：`python3 scripts/ops/promotion-pr-body.py --base staging --head develop` 输出符合 promotion.md 的 markdown 草稿，覆盖 80% 内容；剩余 UAT 必测重点 / 风险等级人工判断 / 上一个 promotion 标签需手工补
- **何时自动加载**：仅 **Gitea 网页 "New Pull Request"** 入口预填到 body 编辑框（默认加载 Feature 模板；Promotion 模板需在网页选择或脚本生成）。**通过 API / `tea` CLI / 自动化脚本创建 PR 不会自动加载**——需手动遵循对应结构填 body
- **AI 生成**：`git-main` skill 在创建 PR 时调用本规范，按结构生成 body
- **审查**：`code-review` skill 的 [`review-checklist.md`](../../.agents/skills/code-review/references/review-checklist.md) 引本文档「评审检查清单」段
- **AI Review 评论结构**：详见 [`code-review/references/ai-review-comment-template.md`](../../.agents/skills/code-review/references/ai-review-comment-template.md)——`ai-review-runner.sh` 输出的评论同样遵循结构化（verdict / 维度矩阵 / 发现分级 / action），跟本规范同源
- **本地前置自检**：commit 前可跑 `bash scripts/dev/ai-review-local.sh`（顺序：ai-review-local → /simplify → commit）。本地用 git diff 跑同一套 schema 输出 7 维度审视，把方向性问题暴露在推 PR 之前。详见 `.agents/skills/code-review/SKILL.md`「本地前置模式」段
