Files
sanguo_moziplus_v2/docs/design/25-gitea-mention-toolchain.md
T
cfdaily 387fa3214f
CI / lint (pull_request) Successful in 6s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 1s
docs: sync §25 design doc help_keywords with actual code
2026-06-12 19:09:23 +08:00

39 KiB
Raw Blame History

§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_mentionssender 排除会失效 当前团队 Gitea 用户名均按 Agent ID 注册,可在 AGENT_ALIAS 中维护映射
A2 前缀模糊匹配唯一时才生效,匹配到多个候选则不匹配(报 warn 日志) @zh 可能同时前缀匹配 zhangfei-devzhuge(如果未来加入) extract_mentions 改为统计匹配数,>1 则跳过并 log warning
A3 Gitea 对 PR 上的普通评论同时发出 issue_commentpull_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 复用。

# 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)

    # 排除自己 @自己(假设 A1Gitea 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。

{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/32pulls/15
source_comments_api_path 评论 API 路径 issues/32/commentspulls/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 PATtoken scope 需包含 issuerepository

意图推断规则

意图推断基于简单的关键词启发式:

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"

    # 求助关键词("帮忙"已由 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"

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 "不做的事"。

# _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. 排除 senderIssue 创建者)
  4. 推断意图、生成响应指引
  5. 发送 mention Mail

自动流转互补分析

  • assigned Mail → 通知 assignee
  • @mention Mail → 通知额外关注者
  • 去重assignee 不再收到 mention Mailshould_suppress_mention

S2+S4: Issue/PR comment @mention — 重构 _handle_issue_comment

⚠️ 关键改动:控制流重构(M2

现状问题:现有 _handle_issue_comment 的控制流是:

# 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

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 还是 IssueGitea 中 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。

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-challengerreview_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。

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

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_commentis_pull=true
  • pull_request_comment

两个事件有不同的 delivery UUIDL1 去重拦不住。

解决方案

  • _EVENT_HANDLERS只注册 issue_comment不注册 pull_request_comment
  • 因为 Gitea 对 PR 普通评论也发 issue_comment(可通过 is_pull 或 issue 中的 pull_request 字段判断)
  • 这样 PR 上的普通评论只会走一条路径,避免双重触发
# _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.pydaemon 侧)

§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 封 Mailreview_request
E2E-5 Review body @mention Review 驳回 body 含 @pangtong-fujunshi 设计有问题 庞统收到 @mention MailPR 作者收到 review_result(不重复)
E2E-6 Review body @PR作者 去重 Review body 含 @zhangfei-dev 请修改 张飞只收 1 封 Mailreview_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 假设文档化