feat(toolchain): §25 Gitea @mention end-to-end integration
New files:
- src/api/mention_utils.py: extract_mentions(), infer_intent(),
_build_response_guidance(), AGENT_ALIAS mapping
- templates/toolchain/mention.md: @mention notification template
with context API + response guidance by intent type
Modified:
- src/api/toolchain_routes.py: S1-S5 handler changes
- S1: Issue body @mention on opened
- S2+S4: _handle_issue_comment control flow refactored
(guard clause → positive if, CI + @mention independent paths)
- S3: PR body @mention on opened
- S5: Review body @mention on submitted
- New _send_mention_mails() with auto-flow suppression
- src/daemon/toolchain_templates.py: register mention template
Design: docs/design/25-gitea-mention-toolchain.md (v2.0)
Tests: 405 passed, 3 skipped
This commit is contained in:
@@ -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)并按需回复。"
|
||||
+213
-26
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user