# §23 — 工具链事件中枢补全：PR 全生命周期通知

> 状态：草案，待评审
> 作者：庞统
> 日期：2026-06-11
> 框架：基于 §20 Task Type Architecture + §13 工具链设计

## 背景

### 问题

工具链事件中枢（`toolchain_routes.py`）当前只覆盖了 PR 生命周期中约一半的交互节点。review 驳回后 PR 作者修改代码，没有机制通知 reviewer 重新 review——流程在这里断链。

### 当前覆盖

| 事件节点 | handler | 模板 | 状态 |
|---|---|---|---|
| PR 创建 → 通知 reviewer | `_handle_pull_request` (opened) | `review_request.md` | ✅ |
| Review 通过 → 通知 PR 作者 | `_handle_pull_request_review` (APPROVED) | `review_result.md` | ✅ |
| Review 驳回 → 通知 PR 作者 | `_handle_pull_request_review` (REQUEST_CHANGES) | `review_result.md` | ✅ |
| Issue 指派 → 通知被指派人 | `_handle_issues` (assigned) | `issue_assigned.md` | ✅ |
| CI 失败评论 → 通知 | `_handle_issue_comment` ([CI]) | `ci_failure.md` | ✅ |
| 部署失败 Issue → 通知 | `_handle_issues` (opened+"部署失败") | `deploy_failure.md` | ✅ |

### 缺失节点

| # | 事件节点 | Gitea 事件 | 优先级 | 理由 |
|---|---|---|---|---|
| E1 | PR 更新（push 新 commit）→ 通知 reviewer | `pull_request.synchronize` | **高** | review 驳回→修改→重 review 的关键闭环 |
| ~~E2~~ | ~~PR 合并通知~~ | ~~已删除~~ | ~~—~~ | ~~和 §22 CD 成功通知重叠，已删~~ |
| E2 | PR 合并 → 通知 PR 作者 + 自动部署 | `pull_request` (closed+merged) | **高** | PR #38 恢复：CD 通知语义不同（部署状态 vs 合并信息），文档 PR 无 CD 流程仍需通知。PR #43：含自动部署（git pull + pm2 restart） |
| E3 | Review 评论（COMMENTED）→ 通知 PR 作者 | `pull_request_review` (COMMENTED) | 中 | reviewer 讨论提问，作者应知道 |
| E4 | PR 上普通评论 → 通知相关人 | `issue_comment` (on PR) | 低 | 非关键路径 |

## 方案

### 框架对齐

按 §20 Task Type Architecture，新增事件处理遵循：
1. `_EVENT_HANDLERS` 映射 → 路由到对应 handler 函数
2. handler 提取变量 → `render_template()` 渲染模板
3. `_TEMPLATE_MAP` 注册模板名 → `templates/toolchain/` 下新建模板文件
4. 通知目标通过 Gitea username → `to_agent_id()` 映射

### 新增 Handler 1：`_handle_pull_request_synchronize`

**触发**：`pull_request` 事件 + `action=synchronize`（PR 分支有新 push）

**通知对象**：PR 的 reviewer（从 PR 的 `requested_reviewers` 或最近一次 non-COMMENTED review 的提交者）

**实现**：

修改 `_handle_pull_request` 的 action 过滤，从只处理 `opened` 扩展为同时处理 `synchronize`：

```python
async def _handle_pull_request(payload: Dict[str, Any]) -> None:
    action = payload.get("action", "")
    if action == "opened":
        await _handle_pr_opened(payload)
    elif action == "synchronize":
        await _handle_pr_synchronize(payload)
    # 其他 action 忽略
```

新增 `_handle_pr_synchronize`：
1. 从 payload 取 PR 信息（number、title、author、head sha）
2. 查询最近一次 review（Gitea API `GET /repos/{owner}/{repo}/pulls/{number}/reviews`）取 reviewer
3. 如果没有 review 记录（`_fetch_latest_reviewer()` 返回 None），fallback 到默认 reviewer `simayi-challenger`，而非跳过通知（PR #38 改动：确保无 review 历史时也能通知默认审查者）
4. 渲染 `review_updated.md` 模板，发送 Mail 给 reviewer

**关键设计决策**：
- 不用 `requested_reviewers`（可能为空），用最近 review 的提交者
- 无 review 历史时 fallback 到默认 reviewer `simayi-challenger`（PR #38：避免 opened + synchronize 间隔较短时 reviewer 未收到任何通知）
- Mail from 用 `system`

### Handler 2：`_handle_pr_closed`（PR 合并通知）— PR #38 恢复

**触发**：`pull_request` 事件 + `action=closed` + `merged=true`

**通知对象**：PR 作者

**实现**：

修改 `_handle_pull_request` 的 action 分发，新增 `closed` 分支：

```python
async def _handle_pull_request(payload: Dict[str, Any]) -> None:
    action = payload.get("action", "")
    if action == "opened":
        await _handle_pr_opened(payload)
    elif action == "synchronize":
        await _handle_pr_synchronize(payload)
    elif action == "closed" and payload.get("pull_request", {}).get("merged"):
        await _handle_pr_closed(payload)
    # 其他 action 忽略
```

新增 `_handle_pr_closed`：
1. 从 payload 取 PR 信息（number、title、merged_by）
2. `merged_by` 优先从 `payload["pull_request"]["merged_by"]` 取，若为空则 fallback 到 `payload["sender"]`（PR #38：兼容不同 Gitea 版本和 merge 方式）
3. 渲染 `review_merged.md` 模板，发送 Mail 给 PR 作者

**恢复说明**：此前因与 §22 CD 成功通知重叠而删除。但实际场景中 CD 通知发的是部署状态，PR 作者更关心的是"谁帮我 merge 了"这个信息，两者语义不同。且 CD 流程不一定每次都触发（如文档 PR），merge 通知仍需独立存在。（PR #38 恢复）

### 新增 Handler 3：review COMMENTED 处理

**触发**：`pull_request_review` 事件 + `state=COMMENTED`

**通知对象**：PR 作者（不是 reviewer）

**实现**：

修改现有 `_handle_pull_request_review`，当前逻辑是"非 COMMENTED 才通知"，改为 COMMENTED 也通知，但用不同模板：

```python
    # 现有逻辑：非 COMMENTED 通知 PR 作者
    if state in ("APPROVED", "REQUEST_CHANGES"):
        template_name = "review_result"
    elif state == "COMMENTED":
        template_name = "review_comment"
    else:
        return  # PENDING 等忽略
```

### 新增模板

| 模板文件 | 变量 | 说明 |
|---|---|---|
| `review_updated.md` | repo, pr_number, pr_title, pr_author, branch, new_sha, reviewer | PR 有新 commit，请重新 review |
| `review_merged.md` | repo, pr_number, pr_title, pr_author, merged_by | PR 已合并，通知作者（PR #38 恢复） |
| `review_comment.md` | repo, pr_number, pr_title, reviewer, comment_body | reviewer 提交了评论 |

### `_EVENT_HANDLERS` 无需改动

`synchronize` 和 `closed` 都是 `pull_request` 事件的 action 子类型，已映射到 `_handle_pull_request`。COMMENTED 是 `pull_request_review` 的 state 子类型，已映射到 `_handle_pull_request_review`。

所以 **`_EVENT_HANDLERS` 不需要修改**，只需修改 handler 内部的 action/state 分发逻辑。

### PR 合并后自动部署（PR #43）

**触发**：`_handle_pr_closed` 合并事件处理完成后

**逻辑**：
1. 读取 `config/deploy-targets.yaml`，查找 `repo` 对应的部署目标
2. 不在配置中 → 跳过（未来新项目加一条配置即可）
3. `git pull origin main`（开发目录）
4. `rsync` 同步到安装目录（排除项由配置指定）
5. 判断是否需要执行 post_deploy：文件路径包含 `src/`、`templates/`、`frontend/` 或 `*.py` 后缀
6. 纯 `docs/` 变更 → 只 pull + rsync，不执行 post_deploy
7. 部署失败复用 `deploy_failure.md` 模板通知 `jiangwei-infra` + `pangtong-fujunshi`

**配置文件**：`config/deploy-targets.yaml`（集中管理所有仓库的部署目标）

```yaml
targets:
  sanguo/sanguo_moziplus_v2:
    dev_dir: ~/.openclaw/sanguo_projects/sanguo_moziplus_v2
    install_dir: ~/.sanguo_projects/sanguo_moziplus_v2
    pm2_name: sanguo-moziplus-v2
    rsync_exclude: [.git/, node_modules/, __pycache__/, data/]
    health_check: http://localhost:8083/health
    post_deploy: [pm2 restart sanguo-moziplus-v2]
```

**设计决策**：
- **集中式 YAML 配置**（姜维建议）：部署是平台级能力，非仓库级。一个文件管所有仓库，新增项目零代码改动
- **YAML 而非 JSON**：支持注释，方便临时禁用某个仓库或排除项
- **post_deploy 列表**：支持任意 shell 命令，不只是 pm2 restart。未来可扩展（如 pip install -e .）
- **health_check 字段**：预留，后续可用于部署后健康检查
- **失败通知复用**：CD 失败和 CI 失败用同一套通知机制（deploy_failure.md 模板 + _send_mail）
- git pull 在开发目录（有 `.git`），rsync 到安装目录
- 全异步调用（asyncio.create_subprocess_exec）

### 不做的事

| 项 | 理由 |
|---|---|
| E4 PR 上普通评论通知 | 低优，非关键路径，后续按需加 |
| Issue 关闭通知 | 低优，关怀性质 |
| reviewer 从 `requested_reviewers` 取 | 不可靠（可能为空），用最近 review 记录更稳定 |

## 改动范围

| 文件 | 改动 |
|---|---|
| `src/api/toolchain_routes.py` | 修改 `_handle_pull_request`（扩展 action 分发 + closed 分支）+ 新增 `_handle_pr_synchronize` + `_handle_pr_closed` + 修改 `_handle_pull_request_review`（支持 COMMENTED） |
| `templates/toolchain/review_updated.md` | 新增 |
| `templates/toolchain/review_merged.md` | 新增（PR #38 恢复） |
| `templates/toolchain/review_comment.md` | 新增 |
| `src/daemon/toolchain_templates.py` | `_TEMPLATE_MAP` 新增 3 个映射 |
| `docs/design/23-toolchain-pr-lifecycle.md` | 本文档 |

## 验证计划

在 `sanguo/moziplus-v2` 测试仓库上 E2E 验证：

1. **synchronize**：创建 PR → review 驳回 → push 新 commit → 验证 reviewer 收到"请重新 review" Mail
2. **synchronize fallback**（PR #38）：创建 PR → push commit（无 review 历史）→ 验证默认 reviewer (`simayi-challenger`) 收到通知
3. **merge 通知**（PR #38 恢复）：PR merge → 验证 PR 作者收到合并通知 Mail
4. **COMMENTED**：review 提交纯评论 → 验证 PR 作者收到通知

## 风险评估

- **风险等级**：低。新增事件处理，不修改现有 handler 逻辑
- **幂等性**：复用现有 `_is_duplicate` 机制
- **性能**：synchronize handler 有一次 Gitea API 调用（查 review 历史），频率低（只在 push 后触发）
