diff --git a/docs/design/22-cd-production.md b/docs/design/22-cd-production.md new file mode 100644 index 0000000..0d3f6f7 --- /dev/null +++ b/docs/design/22-cd-production.md @@ -0,0 +1,81 @@ +# 22 — CD 生产环境落地方案 + +> 状态:草案,待评审 +> 作者:庞统 +> 日期:2026-06-11 + +## 背景 + +CD 管道已在测试仓库 `sanguo/mojiplus-v2` 验证通过(run#282 全链路 success)。 +现需将 CD 落地到生产仓库 `sanguo/sanguo_moziplus_v2`。 + +### 当前状态 + +| 组件 | 状态 | +|---|---| +| `deploy.sh` | ✅ 完整(rsync + build + pm2 restart + health check + deploy history) | +| `deploy.yml` | ⚠️ deploy job 已调用 deploy.sh,但缺少成功通知 | +| CI 失败通知 | ✅ ci.yml → PR comment → webhook → Mail | +| Deploy 失败通知 | ✅ deploy.yml → Issue → webhook → Mail | +| Deploy 成功通知 | ❌ 缺失 | + +## 改动方案 + +### 改动 1:deploy.yml 增加 deploy 成功通知 + +在 deploy job 最后增加一个 step,deploy 成功后: +1. 从 Gitea API 查询触发 commit 关联的 merged PR +2. 获取 PR 作者 +3. 通过 Mail API 发送成功通知给 PR 作者 + pangtong-fujunshi +4. 如果是 direct push(非 PR merge),只通知 jiangwei-infra + pangtong-fujunshi + +**文件**:`.gitea/workflows/deploy.yml` + +**改动范围**:deploy job 内新增 1 个 step(约 30 行 shell) + +**关键逻辑**: +```bash +# 查询关联 PR +PR_INFO=$(curl -sf \ + -H "Authorization: token $GITEA_TOKEN" \ + "$API_URL/repos/$REPO/pulls?state=closed&limit=5" | \ + python3 -c " +import json,sys +for pr in json.load(sys.stdin): + if pr.get('merge_commit_sha','') == '$COMMIT_SHA': + print(pr['user']['login']) + break +" 2>/dev/null || echo "") + +# 发 Mail +curl -s -X POST http://localhost:8083/api/mail \ + -H "Content-Type: application/json" \ + -d "{\"from\":\"daemon\",\"to\":\"$PR_AUTHOR\",\"title\":\"...\",\"text\":\"...\",\"type\":\"inform\"}" +``` + +**约束**: +- 使用 `if: always()` + shell 判断 `needs.deploy.result == "success"`,确保只在成功时执行 +- GITEA_TOKEN 通过 secrets 注入 +- Mail API 调用超时 5 秒,失败不影响部署结果 + +### 不改的文件 + +| 文件 | 原因 | +|---|---| +| `src/api/toolchain_routes.py` | 不新增 webhook 事件,deploy 成功通知在 yml 内闭环 | +| `src/daemon/toolchain_handler.py` | 不涉及 | +| `templates/toolchain/*.md` | 不新增模板,通知内容直接在 shell 中构建 | +| `scripts/deploy.sh` | 已完整,不需改动 | + +## 影响范围 + +- **风险**:低。只在 deploy job 末尾追加通知 step,不修改已有的 deploy/notify 逻辑 +- **回退**:删除新增 step 即可 +- **测试**:push main 后观察 deploy workflow 执行结果 + +## E2E 验证计划 + +1. 在生产仓库创建测试分支,push → PR → merge → 触发 deploy +2. 验证 deploy 成功后 Mail 通知到达 PR 作者 +3. 验证部署文件同步到 `~/.sanguo_projects/sanguo_moziplus_v2/` +4. 验证 health check 通过 diff --git a/docs/design/23-toolchain-pr-lifecycle.md b/docs/design/23-toolchain-pr-lifecycle.md new file mode 100644 index 0000000..0405ed3 --- /dev/null +++ b/docs/design/23-toolchain-pr-lifecycle.md @@ -0,0 +1,144 @@ +# §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 成功通知重叠,已删~~ | +| 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 记录(首次 push 后 reviewer 还没 review),跳过(opened 事件已经通知过了) +4. 渲染 `review_updated.md` 模板,发送 Mail 给 reviewer + +**关键设计决策**: +- 不用 `requested_reviewers`(可能为空),用最近 review 的提交者 +- 只在有 review 历史时才通知(避免 opened + synchronize 重复通知) +- Mail from 用 `system` + +### ~~Handler 2:PR 合并通知~~ — 已删除 + +> 司马懿 review 指出与 §22 CD 成功通知重叠。CD 成功通知已隐含合并信息,无需单独发 merged 通知。 + +### 新增 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 | +| ~~`pr_merged.md`~~ | ~~已删除~~ | ~~—~~ | +| `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 分发逻辑。 + +### 不做的事 + +| 项 | 理由 | +|---|---| +| E4 PR 上普通评论通知 | 低优,非关键路径,后续按需加 | +| Issue 关闭通知 | 低优,关怀性质 | +| reviewer 从 `requested_reviewers` 取 | 不可靠(可能为空),用最近 review 记录更稳定 | + +## 改动范围 + +| 文件 | 改动 | +|---|---| +| `src/api/toolchain_routes.py` | 修改 `_handle_pull_request`(扩展 action 分发)+ 新增 `_handle_pr_synchronize` + 修改 `_handle_pull_request_review`(支持 COMMENTED) | +| `templates/toolchain/review_updated.md` | 新增 | +| ~~`templates/toolchain/pr_merged.md`~~ | ~~已删除~~ | +| `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. merged~~:已删除 +3. **COMMENTED**:review 提交纯评论 → 验证 PR 作者收到通知 + +## 风险评估 + +- **风险等级**:低。新增事件处理,不修改现有 handler 逻辑 +- **幂等性**:复用现有 `_is_duplicate` 机制 +- **性能**:synchronize handler 有一次 Gitea API 调用(查 review 历史),频率低(只在 push 后触发) diff --git a/src/api/toolchain_routes.py b/src/api/toolchain_routes.py index 3855077..4aa467c 100644 --- a/src/api/toolchain_routes.py +++ b/src/api/toolchain_routes.py @@ -77,7 +77,8 @@ def _is_duplicate(event: str, delivery: str, # 取 body 或 content,优先 body(webhookNotifier 格式) content = review.get("body", "") or review.get("content", "") content_hash = hashlib.sha256(content.encode()).hexdigest()[:16] - content_key = f"content:{event}:{pr_num}:{sender}:{content_hash}" + review_id = review.get("id", "") + content_key = f"content:{event}:{pr_num}:{sender}:{review_id}:{content_hash}" if content_key in _delivery_cache: logger.info( "Content-based duplicate detected: %s PR#%s by %s", @@ -258,11 +259,17 @@ def _repo_fullname(payload: Dict[str, Any]) -> str: async def _handle_pull_request(payload: Dict[str, Any]) -> None: - """处理 pull_request 事件:opened → 通知 simayi-challenger。""" + """处理 pull_request 事件:opened → 通知 reviewer;synchronize → 通知 reviewer 重新 review。""" action = payload.get("action", "") - if action != "opened": - return + if action == "opened": + await _handle_pr_opened(payload) + elif action == "synchronize": + await _handle_pr_synchronize(payload) + # 其他 action 忽略 + +async def _handle_pr_opened(payload: Dict[str, Any]) -> None: + """PR opened → 通知 simayi-challenger。""" pr = payload.get("pull_request") if not pr or not isinstance(pr, dict): logger.warning( @@ -327,10 +334,6 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None: } state = type_map.get(review_type, "") - # 只通知 APPROVED 和 REQUEST_CHANGES,跳过 COMMENTED 和其他状态 - if state == "COMMENTED": - return - repo = _repo_fullname(payload) pr_number = pr.get("number", 0) pr_title = pr.get("title", "") @@ -347,6 +350,23 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None: "unknown") review_body = review.get("body", "") or review.get("content", "(无评论)") + if state == "COMMENTED": + # Review 评论 → 通知 PR 作者 + review_body = review.get("body", "") or review.get("content", "(无评论)") + reviewer = review.get("user", {}).get("login", "") or payload.get("sender", {}).get("login", "unknown") + + text = render_template("review_comment", { + "repo": repo, + "pr_number": str(pr_number), + "pr_title": pr_title, + "reviewer": reviewer, + "comment_body": review_body, + }) + + title = f"Review 评论: {pr_title} ({repo}#{pr_number})" + _send_mail(pr_author, title, text) + return + result_map = {"APPROVED": "通过 ✓", "REQUEST_CHANGES": "驳回 ✗"} if state not in result_map: return @@ -365,6 +385,72 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None: _send_mail(pr_author, title, text) +async def _fetch_latest_reviewer(repo: str, pr_number: int) -> str: + """查询 PR 最近一次非 PENDING review 的提交者。 + + Returns: + reviewer login 或空字符串 + """ + if not _GITEA_TOKEN: + return "" + + url = f"{_GITEA_BASE}/repos/{repo}/pulls/{pr_number}/reviews" + headers = {"Authorization": f"token {_GITEA_TOKEN}"} + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + reviews = resp.json() + + # 取最后一个非 PENDING 的 review 的 user + for review in reversed(reviews): + state = review.get("state", "") + if state in ("APPROVED", "REQUEST_CHANGES", "COMMENTED"): + user = review.get("user", {}) + return user.get("login", "") + except Exception as e: + logger.warning("Failed to fetch reviews for %s#%d: %s", repo, pr_number, e) + + return "" + + +async def _handle_pr_synchronize(payload: Dict[str, Any]) -> None: + """PR 更新(新 push)→ 通知 reviewer 重新 review。 + + 查询最近一次 review 的提交者作为通知目标。 + 只在有 review 历史时才通知(避免和 opened 重复)。 + """ + pr = payload.get("pull_request") + if not pr or not isinstance(pr, dict): + return + + repo = _repo_fullname(payload) + pr_number = pr.get("number", 0) + pr_title = pr.get("title", "") + pr_author = pr.get("user", {}).get("login", "unknown") + new_sha = pr.get("head", {}).get("sha", "unknown")[:12] + + # 查询最近 review 的提交者 + reviewer = await _fetch_latest_reviewer(repo, pr_number) + if not reviewer: + # 没有 review 历史,跳过(opened 事件已经通知过) + logger.debug("No review history for PR #%s, skipping synchronize notification", pr_number) + return + + text = render_template("review_updated", { + "repo": repo, + "pr_number": str(pr_number), + "pr_title": pr_title, + "pr_author": pr_author, + "new_sha": new_sha, + "reviewer": reviewer, + }) + + title = f"PR 更新: {pr_title} ({repo}#{pr_number})" + _send_mail(reviewer, title, text) + + async def _handle_issues(payload: Dict[str, Any]) -> None: """处理 issues 事件:assigned → 通知被指派人;opened+部署失败 → 通知运维。""" action = payload.get("action", "") diff --git a/src/daemon/toolchain_templates.py b/src/daemon/toolchain_templates.py index bba8a88..4e3a067 100644 --- a/src/daemon/toolchain_templates.py +++ b/src/daemon/toolchain_templates.py @@ -21,6 +21,8 @@ _TEMPLATE_MAP: Dict[str, str] = { "issue_assigned": "issue_assigned.md", "ci_failure": "ci_failure.md", "deploy_failure": "deploy_failure.md", + "review_updated": "review_updated.md", + "review_comment": "review_comment.md", } # 模板缓存 diff --git a/templates/toolchain/review_comment.md b/templates/toolchain/review_comment.md new file mode 100644 index 0000000..2e49eee --- /dev/null +++ b/templates/toolchain/review_comment.md @@ -0,0 +1,9 @@ +Review 评论 + +PR: http://192.168.2.154:3000/{repo}/pulls/{pr_number} +标题: {pr_title} +评论者: {reviewer} + +{comment_body} + +请查看评论并回复或修改代码。 diff --git a/templates/toolchain/review_updated.md b/templates/toolchain/review_updated.md new file mode 100644 index 0000000..dd9dd05 --- /dev/null +++ b/templates/toolchain/review_updated.md @@ -0,0 +1,16 @@ +PR 有新提交,请重新 Review + +PR: http://192.168.2.154:3000/{repo}/pulls/{pr_number} +标题: {pr_title} +作者: {pr_author} +新 commit: {new_sha} + +您之前已审查过此 PR,作者已根据反馈更新了代码。请重新 Review。 + +流程: +1. 读取 PR diff(Gitea API: GET /repos/{repo}/pulls/{pr_number}.diff) +2. 重点检查上次 Review 意见的修改部分 +3. 提交 Review(Gitea API: POST /repos/{repo}/pulls/{pr_number}/reviews) +4. 提交后改动者会自动收到通知 + +完成后回复此 Mail 确认。