diff --git a/docs/design/25-gitea-mention-toolchain.md b/docs/design/25-gitea-mention-toolchain.md
new file mode 100644
index 0000000..22032bb
--- /dev/null
+++ b/docs/design/25-gitea-mention-toolchain.md
@@ -0,0 +1,802 @@
+# §25 — Gitea @mention 工具链端到端集成
+
+> **状态**: 草案 v2(修订版),待评审
+> **作者**: 庞统(副军师)🐦
+> **日期**: 2026-06-12
+> **框架**: 基于 §13 工具链四层改造 + §23 PR 全生命周期 + §20 Task Type Architecture
+> **前置**: §16 v3.2 已有 `extract_mentions()` + `AGENT_ALIAS` 初步设计(仅覆盖 issue_comment),本篇补全为端到端完整方案
+
+---
+
+## §25.0 背景与动机
+
+### 问题
+
+三国团队在 Gitea 上协作时,Agent 经常需要跨角色沟通:
+
+- 张飞在 Issue 评论 `@赵云 这个数据接口怎么用?`——赵云收不到
+- 庞统在 PR body 写 `@simayi-challenger 请重点审查风控模块`——司马懿没有额外感知
+- 司马懿 review 驳回后评论 `@zhangfei-dev 请注意边界检查`——张飞虽然会收到 review_result Mail,但不知道 specifically @了他
+
+§13.3.2 已有 `extract_mentions()` 初步设计,但只覆盖了 `issue_comment` 场景,且没有和工具链自动流转做去重/互补分析。本设计做完整端到端覆盖。
+
+### 目标
+
+1. **全场景覆盖**:@mention 在 Gitea 所有可写场景(Issue body、Issue comment、PR body、PR comment、Review body)都能触发通知
+2. **和自动流转互补不冗余**:已有自动 Mail(如 Review 请求→司马懿)不因 @mention 重复通知
+3. **幂等安全**:同一事件不会发两封相同 Mail
+4. **闭环可操作**:Agent 收到 mention Mail 后,有明确的"做什么、怎么做"指引
+5. **复用现有架构**:不改 `_EVENT_HANDLERS` 分发结构,只扩展各 handler 内部逻辑
+
+### 关键假设(显式记录)
+
+| # | 假设 | 风险 | 缓解 |
+|---|------|------|------|
+| A1 | **Gitea login = Agent ID**(如 `zhangfei-dev`) | 如果某人 Gitea 用户名与 Agent ID 不同,`extract_mentions` 中 `sender` 排除会失效 | 当前团队 Gitea 用户名均按 Agent ID 注册,可在 `AGENT_ALIAS` 中维护映射 |
+| A2 | 前缀模糊匹配**唯一**时才生效,匹配到多个候选则**不匹配**(报 warn 日志) | `@zh` 可能同时前缀匹配 `zhangfei-dev` 和 `zhuge`(如果未来加入) | `extract_mentions` 改为统计匹配数,>1 则跳过并 log warning |
+| A3 | Gitea 对 PR 上的普通评论**同时**发出 `issue_comment` 和 `pull_request_comment` 两个事件 | 双事件触发会导致重复 Mail | **只注册 `issue_comment` handler**,不注册 `pull_request_comment`(详见 §25.5) |
+
+---
+
+## §25.1 视角 A:@mention 场景遍历
+
+### Gitea 中 @mention 可能出现的 5 种场景
+
+> **注**:原 S6 `commit_comment` 经姜维查阅 Gitea 源码 `modules/webhook/type.go` 确认 Gitea **不存在** `commit_comment` webhook 事件类型。故删除 S6,保留 5 个场景。
+
+| # | 场景 | Gitea Webhook 事件 | 触发时机 | @mention 意图 | 现有自动流转 | 关系 |
+|---|------|-------------------|---------|-------------|------------|------|
+| S1 | **Issue body** 中的 @mention | `issues` (action=opened) | 创建 Issue 时 | 拉人关注、协作请求、分配子任务 | Issue assigned → 通知被指派人 | **互补**:assigned 通知指派人,@mention 通知额外关注者 |
+| S2 | **Issue comment** 中的 @mention | `issue_comment` (action=created) | 在 Issue 下评论时 | 讨论、求助、通知特定人 | CI 失败评论 → 通知 PR 作者 | **互补**:CI 逻辑和 @mention 逻辑互不干扰,同一条评论可同时触发 |
+| S3 | **PR body** 中的 @mention | `pull_request` (action=opened) | 创建 PR 时 | 指定特定 reviewer、拉人关注 PR | PR opened → 通知 simayi-challenger review | **互补**:默认 reviewer 通知照发,@mention 通知额外的人 |
+| S4 | **PR 上的评论**(非 review)中的 @mention | `issue_comment` (on PR, is_pull=true) | 在 PR 页面评论时 | 讨论、提问、拉人参与 | §23 标记为低优先级(E4),未实现 | **增强**:@mention 通知覆盖了 E4 的需求 |
+| S5 | **Review body** 中的 @mention | `pull_request_review` (action=submitted) | 提交 Review 时 | 在 review 中 @PR 作者以外的人 | Review 结果 → 通知 PR 作者 | **互补**:作者照收 review_result/review_comment,@mention 通知额外关注者 |
+
+### 场景意图总结(含闭环行为)
+
+@mention 在工具链中有 4 种核心意图,每种意图对应不同的闭环行为:
+
+| 意图 | 典型场景 | Agent 行为 | 闭环方式 | 闭环操作 |
+|------|---------|-----------|---------|---------|
+| **求助** | `@zhaoyun-data 数据格式是什么?` | 被助者获取上下文并回答 | **在 Gitea 评论回复** | 1. 获取 Issue/PR 详情
2. 回答问题
3. 在 Gitea 评论回复 |
+| **通知关注** | `@guanyu-dev 这个 PR 涉及风控变更` | 被通知者查看并知晓 | **查看 + 知晓**(不强制回复) | 1. 获取 Issue/PR 详情
2. 阅读
3. 如有意见再评论 |
+| **协作请求** | `@pangtong-fujunshi 请帮忙澄清需求` | 被请求者评估并回复 | **评估后回复**(评论或 Mail) | 1. 获取 Issue/PR 详情
2. 评估请求
3. 评论回复或发 Mail |
+| **分配子任务** | `@zhangfei-dev 这个 Issue 的前端交给你` | 被分配者认领并执行 | **认领 + 执行 + 更新状态** | 1. 获取 Issue 详情
2. 评估可行性
3. 认领(assign self)
4. 执行任务
5. 更新 Issue 状态 |
+
+---
+
+## §25.2 视角 B:工具链端到端流程中的 @mention 位置
+
+### 完整生命周期 + @mention 嵌入点
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ 需求阶段 │
+│ ┌──────────┐ issues(opened) ┌──────────┐ │
+│ │ 创建 Issue │ ──────────────────→│ 指派通知 │ ← 自动:assigned→Mail │
+│ │ + @mention │ │ + @mention │ ← 新增:@额外人→Mail │
+│ └──────────┘ └──────────┘ │
+│ │ │ │
+│ │ issue_comment │ │
+│ ↓ ↓ │
+│ ┌──────────┐ issue_comment ┌──────────┐ │
+│ │ 需求讨论 │ ──────────────────→│ @mention │ ← 新增:讨论中@人→Mail │
+│ │ + @mention │ │ 通知 │ │
+│ └──────────┘ └──────────┘ │
+├─────────────────────────────────────────────────────────────────────────┤
+│ 编码阶段 │
+│ (本地开发,无 Gitea @mention 场景) │
+├─────────────────────────────────────────────────────────────────────────┤
+│ PR 阶段 │
+│ ┌──────────┐ pull_request ┌──────────────┐ │
+│ │ 创建 PR │ (opened) ──────────→│ Review 请求 │ ← 自动→simayi │
+│ │ + @mention │ │ + @mention通知│ ← 新增:@额外人→Mail │
+│ └──────────┘ └──────────────┘ │
+│ │ │ │
+│ │ PR 上评论 (issue_comment on PR) │
+│ ↓ ↓ │
+│ ┌──────────┐ issue_comment ┌──────────┐ │
+│ │ PR 讨论 │ ────────────────────→│ @mention │ ← 新增 │
+│ │ + @mention │ │ 通知 │ │
+│ └──────────┘ └──────────┘ │
+├─────────────────────────────────────────────────────────────────────────┤
+│ Review 阶段 │
+│ ┌──────────┐ pull_request_ ┌──────────────┐ │
+│ │ 提交 Review │ review(submitted) ─→│ Review 结果 │ ← 自动→PR作者 │
+│ │ + @mention │ │ + @mention通知│ ← 新增:@额外人→Mail │
+│ └──────────┘ └──────────┘ │
+├─────────────────────────────────────────────────────────────────────────┤
+│ 修复阶段(Review 驳回后) │
+│ PR 作者修改 → push → synchronize → 通知 reviewer 重新 review(§23) │
+│ 讨论中 @mention → 同 S2/S4 │
+├─────────────────────────────────────────────────────────────────────────┤
+│ 合并 + 部署 │
+│ PR merged → 通知 PR 作者 + 自动部署(§23) │
+│ 此阶段无额外 @mention 场景 │
+├─────────────────────────────────────────────────────────────────────────┤
+│ CI/部署验证 │
+│ CI 失败 → 自动通知 PR 作者 │
+│ 评论中 @mention → 同 S2 │
+│ 部署失败 → 自动通知 jiangwei + pangtong │
+│ 无额外 @mention 场景 │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 流程完整性检查
+
+| 检查项 | 结果 |
+|--------|------|
+| 每个环节都有通知机制(自动或 @mention) | ✅ |
+| @mention 不会导致流程断链 | ✅:@mention 是附加通知,不替代自动流转 |
+| @mention 不会导致双重通知同一人 | ✅ 去重机制保障(见 §25.4) |
+| 每种意图都有闭环指引 | ✅ 意图表含闭环方式(见 §25.1) |
+
+---
+
+## §25.3 架构设计
+
+### 25.3.1 核心组件:`mention_utils.py`
+
+抽取 @mention 解析逻辑为独立模块,供所有 handler 复用。
+
+```python
+# src/api/mention_utils.py
+"""@mention 解析工具模块。供所有 toolchain handler 复用。"""
+
+import re
+import logging
+from typing import List, Set
+
+from src.config.agents import AGENT_IDS
+
+logger = logging.getLogger(__name__)
+
+# Gitea API 基地址常量(避免硬编码)
+GITEA_API_BASE = "http://192.168.2.154:3000/api/v1"
+GITEA_WEB_BASE = "http://192.168.2.154:3000"
+
+# Agent 别名映射
+# 规则:
+# 1. 中文名(如"张飞")→ 完整 Agent ID
+# 2. 英文短名(如"zhangfei")→ 完整 Agent ID
+# 3. 前缀模糊匹配需唯一匹配(见 extract_mentions 假设 A2)
+AGENT_ALIAS: dict[str, str] = {
+ # 中文名
+ "张飞": "zhangfei-dev",
+ "关羽": "guanyu-dev",
+ "赵云": "zhaoyun-data",
+ "姜维": "jiangwei-infra",
+ "司马懿": "simayi-challenger",
+ "庞统": "pangtong-fujunshi",
+ # 字+号(常见写法)
+ "翼德": "zhangfei-dev",
+ "云长": "guanyu-dev",
+ "子龙": "zhaoyun-data",
+ "伯约": "jiangwei-infra",
+ "仲达": "simayi-challenger",
+ "士元": "pangtong-fujunshi",
+ # 英文短名
+ "zhangfei": "zhangfei-dev",
+ "guanyu": "guanyu-dev",
+ "zhaoyun": "zhaoyun-data",
+ "jiangwei": "jiangwei-infra",
+ "simayi": "simayi-challenger",
+ "pangtong": "pangtong-fujunshi",
+}
+
+# 正则:匹配 @后面跟着的合法 Agent 名(英文字母/中文/数字/连字符)
+_MENTION_PATTERN = re.compile(r"@([a-zA-Z\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fff-]*)")
+
+
+def extract_mentions(body: str, sender: str) -> list[str]:
+ """从文本中提取 @mention 的 Agent ID 列表。
+
+ Args:
+ body: 评论文本
+ sender: 评论者 Gitea 用户名(用于排除自己 @自己)
+
+ Returns:
+ 去重后的 Agent ID 列表
+
+ 匹配优先级:精确 > 别名 > 前缀模糊(需唯一匹配,多候选则跳过)
+ """
+ candidates = _MENTION_PATTERN.findall(body)
+ result: Set[str] = set()
+
+ for c in candidates:
+ # 1. 精确匹配(@zhangfei-dev)
+ if c in AGENT_IDS:
+ result.add(c)
+ # 2. 别名匹配(@张飞、@zhangfei)
+ elif c in AGENT_ALIAS:
+ result.add(AGENT_ALIAS[c])
+ else:
+ # 3. 前缀模糊匹配(@zhangf → zhangfei-dev)
+ # 假设 A2:多个候选时不匹配,只 log warning
+ matches = [aid for aid in AGENT_IDS if aid.startswith(c)]
+ if len(matches) == 1:
+ result.add(matches[0])
+ elif len(matches) > 1:
+ logger.warning(
+ "Prefix '%s' matched %d agents (%s), skipping ambiguous mention",
+ c, len(matches), matches)
+
+ # 排除自己 @自己(假设 A1:Gitea login = Agent ID)
+ result.discard(sender)
+ return list(result)
+
+
+def should_suppress_mention(
+ mentioned_agent: str,
+ auto_notify_targets: List[str],
+) -> bool:
+ """判断 @mention 通知是否应被抑制(因为自动流转已通知同一人)。
+
+ Args:
+ mentioned_agent: 被 @的 Agent ID
+ auto_notify_targets: 本次事件自动流转已通知的目标列表
+
+ Returns:
+ True 表示应抑制(不发 @mention Mail)
+ """
+ return mentioned_agent in auto_notify_targets
+```
+
+### 25.3.2 通用 Mail 模板:`mention.md`
+
+模板设计对齐 `review_request.md` 的风格:提供完整上下文 + 明确的流程指引 + API endpoint。
+
+```markdown
+{mention_type}通知
+
+来源: {source_type} {source_url}
+评论者: {commenter}
+意图: {intent_hint}
+内容:
+{content_snippet}
+
+📋 获取完整上下文:
+1. 查看{source_type}详情: GET {gitea_api}/repos/{repo}/{source_detail_api_path}
+2. 查看评论列表: GET {gitea_api}/repos/{repo}/{source_comments_api_path}
+
+📌 响应指引:
+{response_guidance}
+
+完成后按指引操作。
+```
+
+变量说明:
+
+| 变量 | 说明 | 示例 |
+|------|------|------|
+| `mention_type` | 场景标签 | `@mention` / `Issue @mention` / `PR @mention` / `Review @mention` |
+| `source_type` | 来源类型 | `Issue` / `PR` / `Review` |
+| `source_url` | 来源 URL(浏览器可访问) | `http://192.168.2.154:3000/sanguo/repo/issues/32` |
+| `commenter` | @mention 发起人 | `zhangfei-dev` |
+| `intent_hint` | 意图提示 | `求助` / `通知关注` / `协作请求` / `分配子任务` |
+| `content_snippet` | 内容摘要(前 500 字符) | `@赵云 数据接口格式是什么?...` |
+| `gitea_api` | Gitea API 基地址(常量) | `http://192.168.2.154:3000/api/v1` |
+| `repo` | 仓库全名 | `sanguo/sanguo_moziplus_v2` |
+| `source_detail_api_path` | 详情 API 路径 | `issues/32` 或 `pulls/15` |
+| `source_comments_api_path` | 评论 API 路径 | `issues/32/comments` 或 `pulls/15/reviews` |
+| `response_guidance` | 按意图生成的响应指引 | 见下方"响应指引生成规则" |
+
+### 响应指引生成规则
+
+根据意图类型,`_send_mention_mails` 函数会注入不同的 `response_guidance` 文本:
+
+**求助(help)**:
+```
+这是一条求助,请到 Gitea 评论回复:
+1. 获取评论上下文(上方 API)
+2. 组织回答
+3. 在 Gitea 评论回复: POST {gitea_api}/repos/{repo}/issues/{number}/comments
+ Body: {"body": "你的回答内容"}
+```
+
+**通知关注(notify)**:
+```
+这是一条通知,请查看并知晓。如有意见,可到 Gitea 评论:
+- 查看{source_type}详情(上方 API)
+- 如有意见,评论回复: POST {gitea_api}/repos/{repo}/issues/{number}/comments
+```
+
+**协作请求(collaborate)**:
+```
+这是一条协作请求,请评估后回复(评论或 Mail):
+1. 获取{source_type}详情(上方 API)
+2. 评估可行性
+3a. 评论回复: POST {gitea_api}/repos/{repo}/issues/{number}/comments
+ Body: {"body": "你的回复"}
+3b. 或通过 Mail 回复评论者: {commenter}
+```
+
+**分配子任务(assign)**:
+```
+这是一条任务分配,请认领并执行:
+1. 获取 Issue 详情(上方 API)
+2. 评估可行性
+3. 认领 Issue: POST {gitea_api}/repos/{repo}/issues/{number}/assignees
+ Body: {"assignees": ["{your_agent_id}"]}
+4. 执行任务
+5. 完成后更新 Issue 状态: PATCH {gitea_api}/repos/{repo}/issues/{number}
+ Body: {"state": "closed"}
+```
+
+> **注**:所有 API 调用需要 Gitea PAT,token scope 需包含 `issue` 或 `repository`。
+
+### 意图推断规则
+
+意图推断基于简单的关键词启发式:
+
+```python
+def infer_intent(body: str, context: str = "") -> str:
+ """从 @mention 内容推断意图。
+
+ Returns:
+ "help" | "notify" | "collaborate" | "assign"
+ """
+ # 分配子任务关键词
+ assign_keywords = ["交给", "分配", "负责", "认领", "做一下", "帮忙做", "implement"]
+ if any(kw in body for kw in assign_keywords):
+ return "assign"
+
+ # 求助关键词
+ help_keywords = ["怎么", "如何", "?", "?", "什么", "哪个", "能否", "帮忙"]
+ if any(kw in body for kw in help_keywords):
+ return "help"
+
+ # 协作请求关键词
+ collab_keywords = ["请帮忙", "请协助", "请澄清", "请review", "请审查", "评估"]
+ if any(kw in body for kw in collab_keywords):
+ return "collaborate"
+
+ # 默认为通知关注
+ return "notify"
+```
+
+### 25.3.3 各场景 Handler 改动
+
+#### S1: Issue body @mention — 改 `_handle_issues`
+
+**改动点**:`_handle_issues` 处理 `opened` action 时,在发送 assigned Mail 后,额外检查 Issue body 中的 @mention。
+
+> **设计决策**:仅处理 `opened`,不处理 `edited`(编辑 Issue 较少见,且可能产生重复通知——首次 opened 已发过)。见 §25.8 "不做的事"。
+
+```python
+# _handle_issues 改动(伪代码)
+async def _handle_issues(payload):
+ action = payload.get("action", "")
+
+ if action == "opened":
+ # ... 现有逻辑:assigned → 发指派 Mail ...
+
+ # 新增:Issue body @mention 通知
+ await _process_issue_body_mentions(payload)
+
+ elif action == "assigned":
+ # 现有逻辑不变
+ ...
+```
+
+**`_process_issue_body_mentions` 逻辑**:
+1. 从 `issue.body` 提取 mentions
+2. 排除 assignee(已被 assigned Mail 通知过)
+3. 排除 sender(Issue 创建者)
+4. 推断意图、生成响应指引
+5. 发送 mention Mail
+
+**自动流转互补分析**:
+- assigned Mail → 通知 assignee
+- @mention Mail → 通知额外关注者
+- **去重**:assignee 不再收到 mention Mail(`should_suppress_mention`)
+
+#### S2+S4: Issue/PR comment @mention — 重构 `_handle_issue_comment`
+
+**⚠️ 关键改动:控制流重构(M2)**
+
+**现状问题**:现有 `_handle_issue_comment` 的控制流是:
+```python
+# toolchain_routes.py 现有代码
+async def _handle_issue_comment(payload):
+ ...
+ if "[CI]" not in body and "CI 失败" not in body:
+ return # ← 非 CI 评论直接 return,@mention 逻辑到不了
+ # ... CI 失败处理 ...
+```
+
+非 CI 评论在第一行就被 `return` 了,无法在 CI 逻辑之后追加 @mention 检测。
+
+**重构方案:改为两条独立路径(if/elif + 并行 @mention)**
+
+```python
+async def _handle_issue_comment(payload):
+ comment = payload.get("comment", {})
+ body = comment.get("body", "")
+ sender = comment.get("user", {}).get("login", "")
+ issue = payload.get("issue", {})
+ action = payload.get("action", "")
+
+ if action != "created":
+ return
+
+ # === 路径 1:CI 失败通知(原有逻辑) ===
+ if "[CI]" in body or "CI 失败" in body:
+ if issue.get("state") != "closed":
+ # ... CI 失败通知逻辑(不变) ...
+ pass
+ # CI 处理完不 return,继续检查 @mention
+
+ # === 路径 2:@mention 通知(新增) ===
+ # 注意:@mention 检测与 CI 检测是独立的,同一条评论可同时触发两者
+ mentions = extract_mentions(body, sender)
+ if mentions:
+ # 判断是 PR 还是 Issue(Gitea 中 PR 本质是特殊的 Issue)
+ is_pr = "pull_request" in issue
+ source_type = "PR" if is_pr else "Issue"
+ mention_type = "PR @mention" if is_pr else "Issue @mention"
+
+ issue_number = issue.get("number", 0)
+
+ # 自动流转已通知的人(CI 失败通知的 PR 作者)
+ auto_targets = []
+ if ("[CI]" in body or "CI 失败" in body) and issue.get("state") != "closed":
+ auto_targets.append(issue.get("user", {}).get("login", ""))
+
+ await _send_mention_mails(
+ mentions=mentions,
+ auto_targets=auto_targets,
+ source_type=source_type,
+ mention_type=mention_type,
+ source_url=issue.get("html_url", ""),
+ commenter=sender,
+ content=body,
+ repo=_repo_fullname(payload),
+ issue_number=issue_number,
+ is_pr=is_pr,
+ )
+```
+
+**重构要点**:
+1. 删除 `if "[CI]" not in body ... return` 的早期退出
+2. CI 检测改为正向 `if`(满足条件才进入 CI 处理),不再用 guard clause 拦截所有非 CI 评论
+3. @mention 检测在 CI 逻辑之后,两条路径独立,不互斥
+4. 同一条评论可同时触发 CI 通知 + @mention 通知(如 CI 失败评论里同时 @了人)
+
+**S2 和 S4 共享同一个 handler**:Gitea 中 PR 上的普通评论也是 `issue_comment` 事件。通过 `issue` 中是否包含 `pull_request` 字段判断是 PR 还是 Issue。
+
+#### S3: PR body @mention — 改 `_handle_pr_opened`
+
+**改动点**:PR opened 时检查 PR body 中的 @mention。
+
+```python
+async def _handle_pr_opened(payload):
+ # ... 现有逻辑:review_request Mail → simayi-challenger ...
+
+ # 新增:PR body @mention 通知
+ pr = payload.get("pull_request", {})
+ body = pr.get("body", "")
+ sender = pr.get("user", {}).get("login", "")
+ mentions = extract_mentions(body, sender)
+
+ if mentions:
+ # 自动流转已通知 simayi-challenger(review_request)
+ auto_targets = ["simayi-challenger"]
+ await _send_mention_mails(
+ mentions=mentions,
+ auto_targets=auto_targets,
+ source_type="PR",
+ mention_type="PR @mention",
+ source_url=pr.get("html_url", ""),
+ commenter=sender,
+ content=body,
+ repo=_repo_fullname(payload),
+ issue_number=pr.get("number", 0),
+ is_pr=True,
+ )
+```
+
+**自动流转互补分析**:
+- review_request Mail → simayi-challenger(自动)
+- @mention Mail → 额外的人(如 `@guanyu-dev 请审查风控`)
+- **去重**:simayi 如果在 PR body 被 @,不会收到两封 Mail
+
+#### S5: Review body @mention — 改 `_handle_pull_request_review`
+
+**改动点**:Review 提交时检查 review body 中的 @mention。
+
+```python
+async def _handle_pull_request_review(payload):
+ review = payload.get("review", {})
+ pr = payload.get("pull_request", {})
+ review_body = review.get("body", "") or review.get("content", "")
+ reviewer = review.get("user", {}).get("login", "") or payload.get("sender", {}).get("login", "")
+ pr_author = pr.get("user", {}).get("login", "")
+
+ # ... 现有逻辑:review_result / review_comment → 通知 PR 作者 ...
+
+ # 新增:Review body @mention 通知
+ mentions = extract_mentions(review_body, reviewer)
+ if mentions:
+ # 自动流转已通知 PR 作者(review_result 或 review_comment)
+ auto_targets = [pr_author]
+ await _send_mention_mails(
+ mentions=mentions,
+ auto_targets=auto_targets,
+ source_type="Review",
+ mention_type="Review @mention",
+ source_url=pr.get("html_url", ""),
+ commenter=reviewer,
+ content=review_body,
+ repo=_repo_fullname(payload),
+ issue_number=pr.get("number", 0),
+ is_pr=True,
+ )
+```
+
+**自动流转互补分析**:
+- review_result/review_comment Mail → PR 作者(自动)
+- @mention Mail → review 中额外 @的人
+- **去重**:PR 作者如果在 review body 被 @,不会收到两封 Mail
+
+### 25.3.4 通用发送函数:`_send_mention_mails`
+
+```python
+async def _send_mention_mails(
+ mentions: list[str],
+ auto_targets: list[str],
+ source_type: str,
+ mention_type: str,
+ source_url: str,
+ commenter: str,
+ content: str,
+ repo: str,
+ issue_number: int,
+ is_pr: bool,
+) -> None:
+ """通用 @mention Mail 发送函数。
+
+ 自动抑制已在 auto_targets 中的 Agent,避免双重通知。
+ 根据内容推断意图,生成不同的响应指引。
+ """
+ # 确定 API 路径(S2:使用常量避免硬编码)
+ if is_pr:
+ detail_api = f"pulls/{issue_number}"
+ comments_api = f"issues/{issue_number}/comments"
+ else:
+ detail_api = f"issues/{issue_number}"
+ comments_api = f"issues/{issue_number}/comments"
+
+ for agent_id in mentions:
+ if should_suppress_mention(agent_id, auto_targets):
+ logger.info(
+ "Mention suppressed for %s (already notified by auto flow)",
+ agent_id)
+ continue
+
+ # 推断意图(S4:注释说明编号提取意图)
+ # 从 api_path 提取编号用于标题,如 "issues/32" → "#32"
+ number_str = f"#{issue_number}" if issue_number else ""
+ intent = infer_intent(content)
+ intent_hint = {"help": "求助", "notify": "通知关注",
+ "collaborate": "协作请求", "assign": "分配子任务"}[intent]
+
+ # 生成响应指引
+ guidance = _build_response_guidance(
+ intent=intent,
+ gitea_api=GITEA_API_BASE,
+ repo=repo,
+ issue_number=issue_number,
+ commenter=commenter,
+ )
+
+ text = render_template("mention", {
+ "mention_type": mention_type,
+ "source_type": source_type,
+ "source_url": source_url,
+ "commenter": commenter,
+ "intent_hint": intent_hint,
+ "content_snippet": content[:500],
+ "gitea_api": GITEA_API_BASE,
+ "repo": repo,
+ "source_detail_api_path": detail_api,
+ "source_comments_api_path": comments_api,
+ "response_guidance": guidance,
+ })
+
+ title = f"@mention ({intent_hint}): {source_type} {number_str} ({repo})"
+ _send_mail(agent_id, title, text)
+
+
+def _build_response_guidance(
+ intent: str,
+ gitea_api: str,
+ repo: str,
+ issue_number: int,
+ commenter: str,
+) -> str:
+ """根据意图类型生成响应指引文本。"""
+ if intent == "help":
+ return (
+ f"这是一条求助,请到 Gitea 评论回复:\n"
+ f"1. 获取评论上下文(上方 API)\n"
+ f"2. 组织回答\n"
+ f"3. 在 Gitea 评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments\n"
+ f" Body: {{\"body\": \"你的回答内容\"}}"
+ )
+ elif intent == "notify":
+ return (
+ f"这是一条通知,请查看并知晓。如有意见,可到 Gitea 评论:\n"
+ f"- 查看 Issue/PR 详情(上方 API)\n"
+ f"- 如有意见,评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments"
+ )
+ elif intent == "collaborate":
+ return (
+ f"这是一条协作请求,请评估后回复(评论或 Mail):\n"
+ f"1. 获取详情(上方 API)\n"
+ f"2. 评估可行性\n"
+ f"3a. 评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments\n"
+ f" Body: {{\"body\": \"你的回复\"}}\n"
+ f"3b. 或通过 Mail 回复评论者: {commenter}"
+ )
+ elif intent == "assign":
+ return (
+ f"这是一条任务分配,请认领并执行:\n"
+ f"1. 获取 Issue 详情(上方 API)\n"
+ f"2. 评估可行性\n"
+ f"3. 认领 Issue: POST {gitea_api}/repos/{repo}/issues/{issue_number}/assignees\n"
+ f" Body: {{\"assignees\": [\"{{your_agent_id}}\"]}}\n"
+ f"4. 执行任务\n"
+ f"5. 完成后更新 Issue 状态: PATCH {gitea_api}/repos/{repo}/issues/{issue_number}\n"
+ f" Body: {{\"state\": \"closed\"}}"
+ )
+ return "请查看详情(上方 API)并按需回复。"
+```
+
+---
+
+## §25.4 幂等与防重复机制
+
+### 五层去重保障
+
+| 层 | 机制 | 覆盖场景 |
+|---|------|---------|
+| **L1: Webhook 投递去重** | `_is_duplicate()` — delivery UUID + 内容哈希 | Gitea 重试、org+repo webhook 双投递 |
+| **L2: extract_mentions 内去重** | 返回 `set`,同一人 @多次只出现一次 | `@张飞 @zhangfei-dev 请看看` |
+| **L3: 自动流转抑制** | `should_suppress_mention()` — 自动已通知的人不再发 mention Mail | simayi 同时是默认 reviewer + 被 @ |
+| **L4: Handler 内排重** | 各 handler 按事件粒度调用,同一事件只处理一次 | PR opened 不会触发两次 `_handle_pr_opened` |
+| **L5: 双事件去重(M3)** | **只注册 `issue_comment` handler,不注册 `pull_request_comment`** | 避免 PR 评论同时触发 `issue_comment` + `pull_request_comment` 导致重复 Mail |
+
+### L5 双事件去重详细说明(M3)
+
+**问题**:Gitea 对 PR 上的普通评论会同时发出两个 webhook 事件:
+- `issue_comment`(`is_pull=true`)
+- `pull_request_comment`
+
+两个事件有不同的 delivery UUID,L1 去重拦不住。
+
+**解决方案**:
+- `_EVENT_HANDLERS` 中**只注册 `issue_comment`**,**不注册 `pull_request_comment`**
+- 因为 Gitea 对 PR 普通评论也发 `issue_comment`(可通过 `is_pull` 或 issue 中的 `pull_request` 字段判断)
+- 这样 PR 上的普通评论只会走一条路径,避免双重触发
+
+```python
+# _EVENT_HANDLERS 中不出现这一行:
+# "pull_request_comment": _handle_pull_request_comment, # ← 不要注册
+```
+
+### 不可去重的场景(正确行为)
+
+| 场景 | 行为 | 原因 |
+|------|------|------|
+| 同一人在不同 PR 分别被 @ | 各发一封 | 不同上下文 |
+| 同一人在同一 Issue 的不同评论被 @ | 各发一封 | 新评论有新信息 |
+| PR 作者收到 review_result + 被 @ | 发两封(不同内容) | review_result 是结构化通知,mention 是额外关注 |
+
+### L3 抑制的详细规则
+
+| 自动流转通知 | @mention 抑制条件 | 理由 |
+|------------|-----------------|------|
+| Issue assigned → assignee | Issue body @assignee → 抑制 | 同一事件,同一人 |
+| PR opened → simayi (review_request) | PR body @simayi-challenger → 抑制 | 同一事件,同一人 |
+| Review result → PR 作者 | Review body @PR作者 → 抑制 | 同一事件,同一人 |
+| Review comment → PR 作者 | Review body @PR作者 → 抑制 | 同一事件,同一人 |
+| CI 失败 → PR 作者 | Issue comment @PR作者(同一条评论)→ 抑制 | 同一事件,同一人 |
+| PR merged → PR 作者 | 不涉及 @mention | merged 事件无 body |
+| Deploy failure → jiangwei+pangtong | 不涉及 @mention | 由 Issue opened 触发 |
+
+**关键设计决策**:抑制是**按事件粒度**的,不是全局的。即只有同一 Webhook 事件产生的自动通知才抑制 @mention。如果 PR 作者先收到 review_result(事件 A),之后在另一条评论中被 @(事件 B),两封 Mail 都会发——这是正确的,因为它们是不同事件。
+
+---
+
+## §25.5 _EVENT_HANDLERS 变更
+
+**结论:不需要修改 `_EVENT_HANDLERS` 映射表。**
+
+@mention 处理是各现有 handler 的内部逻辑扩展:
+
+| Handler | 变更 | 新事件类型? |
+|---------|------|-----------|
+| `_handle_issues` | opened → 增加 body @mention 检测 | 否 |
+| `_handle_issue_comment` | created → **控制流重构** + 增加 comment body @mention 检测 | 否 |
+| `_handle_pr_opened`(在 `_handle_pull_request` 内) | opened → 增加 PR body @mention 检测 | 否 |
+| `_handle_pull_request_review` | submitted → 增加 review body @mention 检测 | 否 |
+| `_handle_pr_synchronize` | 无变更(synchronize 事件无 body) | 否 |
+| `_handle_pr_closed` | 无变更(merged 事件无 body) | 否 |
+
+**注意**:**不注册 `pull_request_comment` handler**,避免 S2/S4 双事件触发(详见 §25.4 L5)。
+
+---
+
+## §25.6 改动范围
+
+| # | 文件 | 改动内容 | 风险 |
+|---|------|---------|------|
+| 1 | **新建** `src/api/mention_utils.py` | `extract_mentions()` + `should_suppress_mention()` + `infer_intent()` + `AGENT_ALIAS` + `GITEA_API_BASE` 常量 | 低(新文件,独立模块) |
+| 2 | **新建** `templates/toolchain/mention.md` | 通用 @mention 通知模板(含响应指引) | 低(新文件) |
+| 3 | `src/api/toolchain_routes.py` | 各 handler 增加 @mention 检测 + `_handle_issue_comment` 控制流重构 + `_send_mention_mails()` + `_build_response_guidance()` | **中**(修改现有 handler 控制流) |
+| 4 | `src/daemon/toolchain_templates.py` | `_TEMPLATE_MAP` 新增 `"mention"` 映射 | 低 |
+| 5 | 不改 | `_EVENT_HANDLERS` 映射(不注册 `pull_request_comment`) | — |
+| 6 | 不改 | 幂等检查机制 | — |
+| 7 | 不改 | 现有模板文件 | — |
+| 8 | 不改 | `toolchain_handler.py`(daemon 侧) | — |
+
+---
+
+## §25.7 验证方案
+
+### 7.1 单元测试
+
+| 测试用例 | 验证点 |
+|---------|--------|
+| `extract_mentions("@zhangfei-dev")` | 精确匹配 |
+| `extract_mentions("@张飞")` | 中文名别名 |
+| `extract_mentions("@zhangfei")` | 英文短名 |
+| `extract_mentions("@翼德")` | 字号别名 |
+| `extract_mentions("@zhangf")` | 前缀模糊匹配(唯一) |
+| `extract_mentions("@zh")` | 前缀匹配多个候选 → 不匹配,不抛异常 |
+| `extract_mentions("@zhangfei-dev @张飞")` | 去重(同一人只出现一次) |
+| `extract_mentions("sender=zhangfei-dev, body=@zhangfei-dev")` | 排除自己 |
+| `extract_mentions("@unknown-person")` | 非 Agent 忽略 |
+| `should_suppress_mention("simayi-challenger", ["simayi-challenger"])` | 抑制生效 |
+| `should_suppress_mention("zhangfei-dev", ["simayi-challenger"])` | 不抑制 |
+| `infer_intent("数据格式是什么?")` | 识别为"求助" |
+| `infer_intent("这个 PR 涉及风控变更")` | 识别为"通知关注" |
+| `infer_intent("请帮忙澄清需求")` | 识别为"协作请求" |
+| `infer_intent("前端部分交给你")` | 识别为"分配子任务" |
+
+### 7.2 E2E 验证场景
+
+| # | 场景 | 操作 | 预期 |
+|---|------|------|------|
+| E2E-1 | Issue body @mention | 创建 Issue body 含 `@赵云 请提供数据字典`,指派张飞 | 张飞收到 assigned Mail;赵云收到 @mention Mail(含"求助"响应指引) |
+| E2E-2 | Issue comment @mention | 在 Issue 下评论 `@pangtong-fujunshi 需求不明确` | 庞统收到 @mention Mail(含"协作请求"响应指引) |
+| E2E-3 | PR body @mention | 创建 PR body 含 `@guanyu-dev 风控审查` | 关羽收到 @mention Mail;司马懿照收 review_request |
+| E2E-4 | PR body @simayi 去重 | 创建 PR body 含 `@simayi-challenger 请审查` | 司马懿只收 1 封 Mail(review_request) |
+| E2E-5 | Review body @mention | Review 驳回 body 含 `@pangtong-fujunshi 设计有问题` | 庞统收到 @mention Mail;PR 作者收到 review_result(不重复) |
+| E2E-6 | Review body @PR作者 去重 | Review body 含 `@zhangfei-dev 请修改` | 张飞只收 1 封 Mail(review_result) |
+| E2E-7 | CI 失败 + @mention 同评论 | CI 失败评论同时含 `@pangtong-fujunshi` | PR 作者收到 CI 失败 Mail;庞统收到 @mention Mail |
+| E2E-8 | PR 上评论 @mention | 在 PR 页面评论 `@zhaoyun-data 数据源是什么?` | 赵云收到 @mention Mail(只触发一次,非双重) |
+| E2E-9 | 非 CI 普通评论 @mention | 在 PR 下评论 `@zhangfei-dev 请注意边界检查`(非 CI 评论) | 张飞收到 @mention Mail(验证重构后非 CI 评论不再被 return 丢弃) |
+| E2E-10 | 前缀模糊多候选 | 评论 `@zh`(假设 zh 开头有多个 Agent) | 不匹配,不发 Mail,日志有 warning |
+
+---
+
+## §25.8 不做的事
+
+| 标记 | 描述 | 原因 |
+|------|------|------|
+| 后续-1 | ~~S6 commit_comment handler 实现~~ | Gitea 不支持 `commit_comment` webhook 事件(姜维已确认源码),搁置 |
+| 后续-2 | Issue `edited` 时 body @mention | 编辑 Issue 较少见,且可能产生重复通知(首次 opened 已发过),暂不处理 |
+| 后续-3 | @mention 触发自动操作(如自动 assign、自动 add reviewer) | 当前只做通知,不做自动化操作。自动化操作需要更复杂的权限和去重逻辑 |
+| 后续-4 | @mention 权限控制(谁能 @谁) | 团队规模小,暂不需要 |
+| 后续-5 | @mention 在 Mail 中的双向回复 | 被通知者按模板中的响应指引操作(去 Gitea 评论或发 Mail),不支持 Mail 直接回复 |
+
+---
+
+## §25.9 变更记录
+
+| 日期 | 版本 | 变更 |
+|------|------|------|
+| 2026-06-12 | v1.0 | 初版:6 场景遍历 + 端到端流程 + 幂等去重 + 和自动流转互补分析 |
+| 2026-06-12 | v2.0 | 修订版(Review 反馈整合):M1 闭环链路 + M2 控制流重构 + M3 双事件去重 + M4 删除 commit_comment + M5 编号改为 §25 + S1 多候选不匹配 + S2 常量化 + S3 删除 edited 分支 + S4 注释说明 + S5 假设文档化 |
diff --git a/src/api/mention_utils.py b/src/api/mention_utils.py
new file mode 100644
index 0000000..5922ff0
--- /dev/null
+++ b/src/api/mention_utils.py
@@ -0,0 +1,169 @@
+"""@mention 解析工具模块。供所有 toolchain handler 复用。"""
+
+import re
+import logging
+from typing import List, Set
+
+from src.config.agents import AGENT_IDS
+
+logger = logging.getLogger(__name__)
+
+# Gitea API 基地址常量(避免硬编码)
+GITEA_API_BASE = "http://192.168.2.154:3000/api/v1"
+GITEA_WEB_BASE = "http://192.168.2.154:3000"
+
+# Agent 别名映射
+# 规则:
+# 1. 中文名(如"张飞")→ 完整 Agent ID
+# 2. 英文短名(如"zhangfei")→ 完整 Agent ID
+# 3. 前缀模糊匹配需唯一匹配(见 extract_mentions 假设 A2)
+AGENT_ALIAS: dict[str, str] = {
+ # 中文名
+ "张飞": "zhangfei-dev",
+ "关羽": "guanyu-dev",
+ "赵云": "zhaoyun-data",
+ "姜维": "jiangwei-infra",
+ "司马懿": "simayi-challenger",
+ "庞统": "pangtong-fujunshi",
+ # 字+号(常见写法)
+ "翼德": "zhangfei-dev",
+ "云长": "guanyu-dev",
+ "子龙": "zhaoyun-data",
+ "伯约": "jiangwei-infra",
+ "仲达": "simayi-challenger",
+ "士元": "pangtong-fujunshi",
+ # 英文短名
+ "zhangfei": "zhangfei-dev",
+ "guanyu": "guanyu-dev",
+ "zhaoyun": "zhaoyun-data",
+ "jiangwei": "jiangwei-infra",
+ "simayi": "simayi-challenger",
+ "pangtong": "pangtong-fujunshi",
+}
+
+# 正则:匹配 @后面跟着的合法 Agent 名(英文字母/中文/数字/连字符)
+_MENTION_PATTERN = re.compile(r"@([a-zA-Z\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fff-]*)")
+
+
+def extract_mentions(body: str, sender: str) -> list[str]:
+ """从文本中提取 @mention 的 Agent ID 列表。
+
+ Args:
+ body: 评论文本
+ sender: 评论者 Gitea 用户名(用于排除自己 @自己)
+
+ Returns:
+ 去重后的 Agent ID 列表
+
+ 匹配优先级:精确 > 别名 > 前缀模糊(需唯一匹配,多候选则跳过)
+ """
+ candidates = _MENTION_PATTERN.findall(body)
+ result: Set[str] = set()
+
+ for c in candidates:
+ # 1. 精确匹配(@zhangfei-dev)
+ if c in AGENT_IDS:
+ result.add(c)
+ # 2. 别名匹配(@张飞、@zhangfei)
+ elif c in AGENT_ALIAS:
+ result.add(AGENT_ALIAS[c])
+ else:
+ # 3. 前缀模糊匹配(@zhangf → zhangfei-dev)
+ # 假设 A2:多个候选时不匹配,只 log warning
+ matches = [aid for aid in AGENT_IDS if aid.startswith(c)]
+ if len(matches) == 1:
+ result.add(matches[0])
+ elif len(matches) > 1:
+ logger.warning(
+ "Prefix '%s' matched %d agents (%s), skipping ambiguous mention",
+ c, len(matches), matches)
+
+ # 排除自己 @自己(假设 A1:Gitea login = Agent ID)
+ result.discard(sender)
+ return list(result)
+
+
+def should_suppress_mention(
+ mentioned_agent: str,
+ auto_notify_targets: List[str],
+) -> bool:
+ """判断 @mention 通知是否应被抑制(因为自动流转已通知同一人)。
+
+ Args:
+ mentioned_agent: 被 @的 Agent ID
+ auto_notify_targets: 本次事件自动流转已通知的目标列表
+
+ Returns:
+ True 表示应抑制(不发 @mention Mail)
+ """
+ return mentioned_agent in auto_notify_targets
+
+
+def infer_intent(body: str) -> str:
+ """从 @mention 内容推断意图。
+
+ Returns:
+ "help" | "notify" | "collaborate" | "assign"
+ """
+ # 分配子任务关键词
+ assign_keywords = ["交给", "分配", "负责", "认领", "做一下", "帮忙做", "implement"]
+ if any(kw in body for kw in assign_keywords):
+ return "assign"
+
+ # 求助关键词(注意:"帮忙"已由 assign_keywords 的"帮忙做"覆盖,"请帮忙"由 collab_keywords 覆盖)
+ help_keywords = ["怎么", "如何", "?", "?", "什么", "哪个", "能否"]
+ if any(kw in body for kw in help_keywords):
+ return "help"
+
+ # 协作请求关键词
+ collab_keywords = ["请帮忙", "请协助", "请澄清", "请review", "请审查", "评估"]
+ if any(kw in body for kw in collab_keywords):
+ return "collaborate"
+
+ # 默认为通知关注
+ return "notify"
+
+
+def _build_response_guidance(
+ intent: str,
+ gitea_api: str,
+ repo: str,
+ issue_number: int,
+ commenter: str,
+) -> str:
+ """根据意图类型生成响应指引文本。"""
+ if intent == "help":
+ return (
+ f"这是一条求助,请到 Gitea 评论回复:\n"
+ f"1. 获取评论上下文(上方 API)\n"
+ f"2. 组织回答\n"
+ f"3. 在 Gitea 评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments\n"
+ f' Body: {{"body": "你的回答内容"}}'
+ )
+ elif intent == "notify":
+ return (
+ f"这是一条通知,请查看并知晓。如有意见,可到 Gitea 评论:\n"
+ f"- 查看 Issue/PR 详情(上方 API)\n"
+ f"- 如有意见,评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments"
+ )
+ elif intent == "collaborate":
+ return (
+ f"这是一条协作请求,请评估后回复(评论或 Mail):\n"
+ f"1. 获取详情(上方 API)\n"
+ f"2. 评估可行性\n"
+ f"3a. 评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments\n"
+ f' Body: {{"body": "你的回复"}}\n'
+ f"3b. 或通过 Mail 回复评论者: {commenter}"
+ )
+ elif intent == "assign":
+ return (
+ f"这是一条任务分配,请认领并执行:\n"
+ f"1. 获取 Issue 详情(上方 API)\n"
+ f"2. 评估可行性\n"
+ f"3. 认领 Issue: POST {gitea_api}/repos/{repo}/issues/{issue_number}/assignees\n"
+ f' Body: {{"assignees": ["{{your_agent_id}}"]}}\n'
+ f"4. 执行任务\n"
+ f"5. 完成后更新 Issue 状态: PATCH {gitea_api}/repos/{repo}/issues/{issue_number}\n"
+ f' Body: {{"state": "closed"}}'
+ )
+ return "请查看详情(上方 API)并按需回复。"
diff --git a/src/api/toolchain_routes.py b/src/api/toolchain_routes.py
index cb4301f..1881558 100644
--- a/src/api/toolchain_routes.py
+++ b/src/api/toolchain_routes.py
@@ -27,6 +27,13 @@ from src.blackboard.db import init_db
from src.blackboard.models import Task
from src.blackboard.operations import Blackboard
from src.config.agents import AGENT_IDS
+from src.api.mention_utils import (
+ extract_mentions,
+ should_suppress_mention,
+ infer_intent,
+ _build_response_guidance,
+ GITEA_API_BASE,
+)
from src.daemon.toolchain_templates import render_template
from src.utils import get_data_root
@@ -253,6 +260,76 @@ def _repo_fullname(payload: Dict[str, Any]) -> str:
return repo.get("full_name", "")
+# ---------------------------------------------------------------------------
+# @mention 通用发送函数
+# ---------------------------------------------------------------------------
+
+
+async def _send_mention_mails(
+ mentions: list[str],
+ auto_targets: list[str],
+ source_type: str,
+ mention_type: str,
+ source_url: str,
+ commenter: str,
+ content: str,
+ repo: str,
+ issue_number: int,
+ is_pr: bool,
+) -> None:
+ """通用 @mention Mail 发送函数。
+
+ 自动抑制已在 auto_targets 中的 Agent,避免双重通知。
+ 根据内容推断意图,生成不同的响应指引。
+ """
+ # 确定 API 路径
+ if is_pr:
+ detail_api = f"pulls/{issue_number}"
+ comments_api = f"issues/{issue_number}/comments"
+ else:
+ detail_api = f"issues/{issue_number}"
+ comments_api = f"issues/{issue_number}/comments"
+
+ for agent_id in mentions:
+ if should_suppress_mention(agent_id, auto_targets):
+ logger.info(
+ "Mention suppressed for %s (already notified by auto flow)",
+ agent_id)
+ continue
+
+ # 从 api_path 提取编号用于标题,如 "issues/32" → "#32"
+ number_str = f"#{issue_number}" if issue_number else ""
+ intent = infer_intent(content)
+ intent_hint = {"help": "求助", "notify": "通知关注",
+ "collaborate": "协作请求", "assign": "分配子任务"}[intent]
+
+ # 生成响应指引
+ guidance = _build_response_guidance(
+ intent=intent,
+ gitea_api=GITEA_API_BASE,
+ repo=repo,
+ issue_number=issue_number,
+ commenter=commenter,
+ )
+
+ text = render_template("mention", {
+ "mention_type": mention_type,
+ "source_type": source_type,
+ "source_url": source_url,
+ "commenter": commenter,
+ "intent_hint": intent_hint,
+ "content_snippet": content[:500],
+ "gitea_api": GITEA_API_BASE,
+ "repo": repo,
+ "source_detail_api_path": detail_api,
+ "source_comments_api_path": comments_api,
+ "response_guidance": guidance,
+ })
+
+ title = f"@mention ({intent_hint}): {source_type} {number_str} ({repo})"
+ _send_mail(agent_id, title, text)
+
+
# ---------------------------------------------------------------------------
# 事件处理函数
# ---------------------------------------------------------------------------
@@ -302,6 +379,52 @@ async def _handle_pr_opened(payload: Dict[str, Any]) -> None:
title = f"Review 请求: {pr_title} ({repo}#{pr_number})"
_send_mail("simayi-challenger", title, text)
+ # S3: PR body @mention 通知
+ pr_body = pr.get("body", "") or ""
+ sender = pr.get("user", {}).get("login", "")
+ mentions = extract_mentions(pr_body, sender)
+ if mentions:
+ # 自动流转已通知 simayi-challenger(review_request)
+ auto_targets = ["simayi-challenger"]
+ await _send_mention_mails(
+ mentions=mentions,
+ auto_targets=auto_targets,
+ source_type="PR",
+ mention_type="PR @mention",
+ source_url=pr.get("html_url", ""),
+ commenter=sender,
+ content=pr_body,
+ repo=repo,
+ issue_number=pr_number,
+ is_pr=True,
+ )
+
+
+async def _send_review_mentions(
+ review_body: str,
+ reviewer: str,
+ pr_author: str,
+ pr: dict,
+ repo: str,
+ pr_number: int,
+) -> None:
+ """提取并发送 Review body 中的 @mention 通知(COMMENTED / 非 COMMENTED 通用)。"""
+ mentions = extract_mentions(review_body, reviewer)
+ if mentions:
+ auto_targets = [pr_author]
+ await _send_mention_mails(
+ mentions=mentions,
+ auto_targets=auto_targets,
+ source_type="Review",
+ mention_type="Review @mention",
+ source_url=pr.get("html_url", ""),
+ commenter=reviewer,
+ content=review_body,
+ repo=repo,
+ issue_number=pr_number,
+ is_pr=True,
+ )
+
async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
"""处理 pull_request_review 事件:非 COMMENTED → 通知 PR 作者。
@@ -364,6 +487,10 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
title = f"Review 评论: {pr_title} ({repo}#{pr_number})"
_send_mail(pr_author, title, text)
+
+ # S5: Review body @mention 通知(COMMENTED 路径)
+ await _send_review_mentions(review_body, reviewer, pr_author, pr, repo, pr_number)
+
return
result_map = {"APPROVED": "通过 ✓", "REQUEST_CHANGES": "驳回 ✗"}
@@ -383,6 +510,9 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
title = f"Review {result}: {pr_title} ({repo}#{pr_number})"
_send_mail(pr_author, title, text)
+ # S5: Review body @mention 通知(非 COMMENTED 路径)
+ await _send_review_mentions(review_body, reviewer, pr_author, pr, repo, pr_number)
+
async def _fetch_latest_reviewer(repo: str, pr_number: int) -> str:
"""查询 PR 最近一次非 PENDING review 的提交者。
@@ -566,7 +696,6 @@ async def _handle_pr_closed(payload: Dict[str, Any]) -> None:
self_restart = False
if pm2_name and os.environ.get("PM2_HOME") and "pm2 restart" in cmd:
# 检查命令是否包含当前进程名
- import re
if re.search(rf'pm2\s+restart\s+{re.escape(pm2_name)}', cmd):
self_restart = True
@@ -658,65 +787,118 @@ async def _handle_issues(payload: Dict[str, Any]) -> None:
title = f"Issue 指派: {issue_title} ({repo}#{issue_number})"
_send_mail(assignee, title, text)
- elif action == "opened" and "部署失败" in issue_title:
- # 从 Issue body 提取 commit hash(Gitea deploy workflow 格式)
- sha_match = re.search(r'[0-9a-f]{40}', issue.get("body", ""))
- commit_sha = sha_match.group(0) if sha_match else "(未知)"
+ elif action == "opened":
+ if "部署失败" in issue_title:
+ # 从 Issue body 提取 commit hash(Gitea deploy workflow 格式)
+ sha_match = re.search(r'[0-9a-f]{40}', issue.get("body", ""))
+ commit_sha = sha_match.group(0) if sha_match else "(未知)"
- text = render_template("deploy_failure", {
- "repo": repo,
- "commit_sha": commit_sha or "(未知)",
- })
+ text = render_template("deploy_failure", {
+ "repo": repo,
+ "commit_sha": commit_sha or "(未知)",
+ })
- title = f"部署失败: {repo}"
- for agent_id in ("jiangwei-infra", "pangtong-fujunshi"):
- _send_mail(agent_id, title, text)
+ title = f"部署失败: {repo}"
+ for agent_id in ("jiangwei-infra", "pangtong-fujunshi"):
+ _send_mail(agent_id, title, text)
+
+ # Issue body @mention(opened 时检查)
+ issue_body = issue.get("body", "") or ""
+ sender = payload.get("sender", {}).get("login", "")
+ mentions = extract_mentions(issue_body, sender)
+ if mentions:
+ # 自动流转已通知 assignee
+ assignees = issue.get("assignees") or []
+ if not assignees:
+ single = issue.get("assignee")
+ if single and isinstance(single, dict):
+ assignees = [single]
+ auto_targets = [a.get("login", "") for a in assignees if isinstance(a, dict)]
+ await _send_mention_mails(
+ mentions=mentions,
+ auto_targets=auto_targets,
+ source_type="Issue",
+ mention_type="Issue @mention",
+ source_url=issue.get("html_url", ""),
+ commenter=sender,
+ content=issue_body,
+ repo=repo,
+ issue_number=issue_number,
+ is_pr=False,
+ )
async def _handle_issue_comment(payload: Dict[str, Any]) -> None:
- """处理 issue_comment 事件:CI 失败关键词 → 通知 PR 作者。"""
+ """处理 issue_comment 事件:CI 失败关键词 → 通知 PR 作者;@mention → 通知被提及者。"""
comment = payload.get("comment")
if not comment or not isinstance(comment, dict):
logger.warning("issue_comment event missing comment field, skipping")
return
body = comment.get("body", "")
-
- # 检查是否包含 CI 失败关键词
- if "[CI]" not in body and "CI 失败" not in body:
- return
+ sender = comment.get("user", {}).get("login", "")
issue = payload.get("issue")
if not issue or not isinstance(issue, dict):
logger.warning("issue_comment event missing issue field, skipping")
return
- # 已关闭的 Issue/PR 不再发送 CI 失败通知
- if issue.get("state") == "closed":
- logger.debug(
- "Skipping CI failure notification for closed issue #%s",
- issue.get("number"))
+ action = payload.get("action", "")
+ if action != "created":
return
- repo = _repo_fullname(payload)
- issue_number = issue.get("number", 0)
+ # === 路径 1:CI 失败通知(原有逻辑,改为正向 if) ===
+ if ("[CI]" in body or "CI 失败" in body) and issue.get("state") != "closed":
+ repo = _repo_fullname(payload)
+ issue_number = issue.get("number", 0)
- # 尝试从关联 PR 获取信息
- pr_author = issue.get("user", {}).get("login", "unknown")
- branch_match = re.search(r"分支:\s*(\S+)", body)
- branch = branch_match.group(1) if branch_match else "(未知)"
+ # 尝试从关联 PR 获取信息
+ pr_author = issue.get("user", {}).get("login", "unknown")
+ branch_match = re.search(r"分支:\s*(\S+)", body)
+ branch = branch_match.group(1) if branch_match else "(未知)"
- # 提取错误摘要(取 comment body 前 500 字符)
- error_summary = body[:500] if body else "(无错误信息)"
+ # 提取错误摘要(取 comment body 前 500 字符)
+ error_summary = body[:500] if body else "(无错误信息)"
- text = render_template("ci_failure", {
- "repo": repo,
- "pr_number": str(issue_number),
- "branch": branch,
- "error_summary": error_summary,
- })
+ text = render_template("ci_failure", {
+ "repo": repo,
+ "pr_number": str(issue_number),
+ "branch": branch,
+ "error_summary": error_summary,
+ })
- title = f"CI 失败: {repo}#{issue_number}"
- _send_mail(pr_author, title, text)
+ title = f"CI 失败: {repo}#{issue_number}"
+ _send_mail(pr_author, title, text)
+ # CI 处理完不 return,继续检查 @mention
+
+ # === 路径 2:@mention 通知(新增,独立路径) ===
+ # 注意:@mention 检测与 CI 检测是独立的,同一条评论可同时触发两者
+ mentions = extract_mentions(body, sender)
+ if mentions:
+ # 判断是 PR 还是 Issue(Gitea 中 PR 本质是特殊的 Issue)
+ is_pr = "pull_request" in issue
+ source_type = "PR" if is_pr else "Issue"
+ mention_type = "PR @mention" if is_pr else "Issue @mention"
+
+ issue_number = issue.get("number", 0)
+ repo = _repo_fullname(payload)
+
+ # 自动流转已通知的人(CI 失败通知的 PR 作者)
+ auto_targets: list[str] = []
+ if ("[CI]" in body or "CI 失败" in body) and issue.get("state") != "closed":
+ auto_targets.append(issue.get("user", {}).get("login", ""))
+
+ await _send_mention_mails(
+ mentions=mentions,
+ auto_targets=auto_targets,
+ source_type=source_type,
+ mention_type=mention_type,
+ source_url=issue.get("html_url", ""),
+ commenter=sender,
+ content=body,
+ repo=repo,
+ issue_number=issue_number,
+ is_pr=is_pr,
+ )
# ---------------------------------------------------------------------------
diff --git a/src/daemon/toolchain_templates.py b/src/daemon/toolchain_templates.py
index c5d55f5..44ab599 100644
--- a/src/daemon/toolchain_templates.py
+++ b/src/daemon/toolchain_templates.py
@@ -24,6 +24,7 @@ _TEMPLATE_MAP: Dict[str, str] = {
"review_updated": "review_updated.md",
"review_comment": "review_comment.md",
"review_merged": "review_merged.md",
+ "mention": "mention.md",
}
# 模板缓存
diff --git a/templates/toolchain/mention.md b/templates/toolchain/mention.md
new file mode 100644
index 0000000..22d2895
--- /dev/null
+++ b/templates/toolchain/mention.md
@@ -0,0 +1,16 @@
+{mention_type}通知
+
+来源: {source_type} {source_url}
+评论者: {commenter}
+意图: {intent_hint}
+内容:
+{content_snippet}
+
+📋 获取完整上下文:
+1. 查看{source_type}详情: GET {gitea_api}/repos/{repo}/{source_detail_api_path}
+2. 查看评论列表: GET {gitea_api}/repos/{repo}/{source_comments_api_path}
+
+📌 响应指引:
+{response_guidance}
+
+完成后按指引操作。
diff --git a/tests/unit/test_mention_utils.py b/tests/unit/test_mention_utils.py
new file mode 100644
index 0000000..0a32cc7
--- /dev/null
+++ b/tests/unit/test_mention_utils.py
@@ -0,0 +1,129 @@
+"""mention_utils 单元测试 — §25.7 覆盖。"""
+
+import pytest
+
+from src.api.mention_utils import (
+ extract_mentions,
+ should_suppress_mention,
+ infer_intent,
+)
+
+
+# ---------------------------------------------------------------------------
+# extract_mentions
+# ---------------------------------------------------------------------------
+
+class TestExtractMentions:
+ """测试 @mention 提取逻辑。"""
+
+ def test_exact_match(self):
+ """@zhangfei-dev 精确匹配。"""
+ assert extract_mentions("@zhangfei-dev 请看一下", "someone") == ["zhangfei-dev"]
+
+ def test_chinese_alias(self):
+ """@张飞 中文别名匹配。"""
+ assert extract_mentions("@张飞 帮忙看看", "someone") == ["zhangfei-dev"]
+
+ def test_english_short_name(self):
+ """@zhangfei 英文短名匹配。"""
+ assert extract_mentions("@zhangfei 快来", "someone") == ["zhangfei-dev"]
+
+ def test_prefix_unique(self):
+ """@zhangf 前缀唯一匹配。"""
+ assert extract_mentions("@zhangf 来一下", "someone") == ["zhangfei-dev"]
+
+ def test_prefix_ambiguous_no_match(self):
+ """@z 前缀模糊,多个候选,不匹配。"""
+ assert extract_mentions("@z 看看", "someone") == []
+
+ def test_dedup_same_person(self):
+ """@张飞 @zhangfei-dev 同时出现去重。"""
+ result = extract_mentions("@张飞 @zhangfei-dev 来一下", "someone")
+ assert result == ["zhangfei-dev"]
+
+ def test_exclude_self(self):
+ """@zhangfei-dev 排除自己(sender=zhangfei-dev)。"""
+ assert extract_mentions("@zhangfei-dev 自己说", "zhangfei-dev") == []
+
+ def test_unknown_person(self):
+ """@unknown 不匹配任何 Agent。"""
+ assert extract_mentions("@unknown 你好", "someone") == []
+
+ def test_multiple_mentions(self):
+ """多个 @mention 返回多个 Agent。"""
+ result = set(extract_mentions("@张飞 @关羽 来讨论", "someone"))
+ assert result == {"zhangfei-dev", "guanyu-dev"}
+
+ def test_mention_with_hyphen_in_middle(self):
+ """@mention 后面紧跟标点也能识别。"""
+ result = extract_mentions("@赵云,请看下", "someone")
+ assert result == ["zhaoyun-data"]
+
+
+# ---------------------------------------------------------------------------
+# should_suppress_mention
+# ---------------------------------------------------------------------------
+
+class TestShouldSuppressMention:
+ """测试 @mention 通知抑制逻辑。"""
+
+ def test_suppress_when_in_list(self):
+ """被提及者在自动通知列表中 → 抑制。"""
+ assert should_suppress_mention("zhangfei-dev", ["zhangfei-dev", "guanyu-dev"]) is True
+
+ def test_not_suppress_when_not_in_list(self):
+ """被提及者不在自动通知列表中 → 不抑制。"""
+ assert should_suppress_mention("zhangfei-dev", ["guanyu-dev"]) is False
+
+ def test_suppress_empty_list(self):
+ """自动通知列表为空 → 不抑制。"""
+ assert should_suppress_mention("zhangfei-dev", []) is False
+
+
+# ---------------------------------------------------------------------------
+# infer_intent
+# ---------------------------------------------------------------------------
+
+class TestInferIntent:
+ """测试意图推断逻辑。
+
+ 优先级:assign → collaborate → help → notify(默认)
+ """
+
+ def test_help_question_mark(self):
+ """疑问句 → help。"""
+ assert infer_intent("@赵云 数据格式是什么?") == "help"
+
+ def test_notify_plain_mention(self):
+ """纯通知(无关键词) → notify。"""
+ assert infer_intent("@关羽 这个 PR 涉及风控变更") == "notify"
+
+ def test_collaborate_please_help(self):
+ """'请帮忙' → collaborate(NOT help!)。"""
+ assert infer_intent("@庞统 请帮忙澄清需求") == "collaborate"
+
+ def test_assign_keywords(self):
+ """'交给你' → assign。"""
+ assert infer_intent("@张飞 前端部分交给你") == "assign"
+
+ def test_help_how_to(self):
+ """'如何' → help。"""
+ assert infer_intent("@姜维 如何部署这个服务") == "help"
+
+ def test_collaborate_please_review(self):
+ """'请review' → collaborate。"""
+ assert infer_intent("@司马懿 请review 这个方案") == "collaborate"
+
+ def test_notify_default(self):
+ """无任何关键词 → notify。"""
+ assert infer_intent("@赵云 已更新数据") == "notify"
+
+ def test_assign_takes_priority_over_help(self):
+ """assign 关键词优先于 help 关键词。"""
+ # "交给" in body → assign, even though "?" also present
+ assert infer_intent("@张飞 这个模块交给你,有问题?") == "assign"
+
+ def test_collaborate_takes_priority_over_help(self):
+ """collaborate 关键词优先于 help 关键词。"""
+ # "请帮忙" in body → collaborate, even though "?" absent
+ assert infer_intent("@赵云 请帮忙看看数据") == "collaborate"