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..cc9c4d2 --- /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" + + # 求助关键词 + 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..c503502 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,26 @@ 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 _handle_pull_request_review(payload: Dict[str, Any]) -> None: """处理 pull_request_review 事件:非 COMMENTED → 通知 PR 作者。 @@ -364,6 +461,25 @@ 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 路径) + mentions = extract_mentions(review_body, reviewer) + if mentions: + # 自动流转已通知 PR 作者(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, + issue_number=pr_number, + is_pr=True, + ) + return result_map = {"APPROVED": "通过 ✓", "REQUEST_CHANGES": "驳回 ✗"} @@ -383,6 +499,24 @@ 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 路径) + mentions = extract_mentions(review_body, reviewer) + if mentions: + # 自动流转已通知 PR 作者(review_result) + 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 _fetch_latest_reviewer(repo: str, pr_number: int) -> str: """查询 PR 最近一次非 PENDING review 的提交者。 @@ -672,51 +806,104 @@ async def _handle_issues(payload: Dict[str, Any]) -> None: for agent_id in ("jiangwei-infra", "pangtong-fujunshi"): _send_mail(agent_id, title, text) + # S1: Issue body @mention(opened 时检查) + if action == "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} + +完成后按指引操作。