feat(toolchain): add PR synchronize and review comment notifications
CI / lint (pull_request) Successful in 6s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 0s

- pull_request.synchronize: notify reviewer to re-review after push
- pull_request_review COMMENTED: notify PR author of review comments
- New templates: review_updated.md, review_comment.md
- Idempotency: add review ID to content dedup key
- Design doc: docs/design/23-toolchain-pr-lifecycle.md
This commit is contained in:
cfdaily
2026-06-11 14:00:44 +08:00
parent d6612de6de
commit a4bb752d71
6 changed files with 346 additions and 8 deletions
+94 -8
View File
@@ -77,7 +77,8 @@ def _is_duplicate(event: str, delivery: str,
# 取 body 或 content,优先 bodywebhookNotifier 格式)
content = review.get("body", "") or review.get("content", "")
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
content_key = f"content:{event}:{pr_num}:{sender}:{content_hash}"
review_id = review.get("id", "")
content_key = f"content:{event}:{pr_num}:{sender}:{review_id}:{content_hash}"
if content_key in _delivery_cache:
logger.info(
"Content-based duplicate detected: %s PR#%s by %s",
@@ -258,11 +259,17 @@ def _repo_fullname(payload: Dict[str, Any]) -> str:
async def _handle_pull_request(payload: Dict[str, Any]) -> None:
"""处理 pull_request 事件:opened → 通知 simayi-challenger"""
"""处理 pull_request 事件:opened → 通知 reviewersynchronize → 通知 reviewer 重新 review"""
action = payload.get("action", "")
if action != "opened":
return
if action == "opened":
await _handle_pr_opened(payload)
elif action == "synchronize":
await _handle_pr_synchronize(payload)
# 其他 action 忽略
async def _handle_pr_opened(payload: Dict[str, Any]) -> None:
"""PR opened → 通知 simayi-challenger。"""
pr = payload.get("pull_request")
if not pr or not isinstance(pr, dict):
logger.warning(
@@ -327,10 +334,6 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
}
state = type_map.get(review_type, "")
# 只通知 APPROVED 和 REQUEST_CHANGES,跳过 COMMENTED 和其他状态
if state == "COMMENTED":
return
repo = _repo_fullname(payload)
pr_number = pr.get("number", 0)
pr_title = pr.get("title", "")
@@ -347,6 +350,23 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
"unknown")
review_body = review.get("body", "") or review.get("content", "(无评论)")
if state == "COMMENTED":
# Review 评论 → 通知 PR 作者
review_body = review.get("body", "") or review.get("content", "(无评论)")
reviewer = review.get("user", {}).get("login", "") or payload.get("sender", {}).get("login", "unknown")
text = render_template("review_comment", {
"repo": repo,
"pr_number": str(pr_number),
"pr_title": pr_title,
"reviewer": reviewer,
"comment_body": review_body,
})
title = f"Review 评论: {pr_title} ({repo}#{pr_number})"
_send_mail(pr_author, title, text)
return
result_map = {"APPROVED": "通过 ✓", "REQUEST_CHANGES": "驳回 ✗"}
if state not in result_map:
return
@@ -365,6 +385,72 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
_send_mail(pr_author, title, text)
async def _fetch_latest_reviewer(repo: str, pr_number: int) -> str:
"""查询 PR 最近一次非 PENDING review 的提交者。
Returns:
reviewer login 或空字符串
"""
if not _GITEA_TOKEN:
return ""
url = f"{_GITEA_BASE}/repos/{repo}/pulls/{pr_number}/reviews"
headers = {"Authorization": f"token {_GITEA_TOKEN}"}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
reviews = resp.json()
# 取最后一个非 PENDING 的 review 的 user
for review in reversed(reviews):
state = review.get("state", "")
if state in ("APPROVED", "REQUEST_CHANGES", "COMMENTED"):
user = review.get("user", {})
return user.get("login", "")
except Exception as e:
logger.warning("Failed to fetch reviews for %s#%d: %s", repo, pr_number, e)
return ""
async def _handle_pr_synchronize(payload: Dict[str, Any]) -> None:
"""PR 更新(新 push)→ 通知 reviewer 重新 review。
查询最近一次 review 的提交者作为通知目标。
只在有 review 历史时才通知(避免和 opened 重复)。
"""
pr = payload.get("pull_request")
if not pr or not isinstance(pr, dict):
return
repo = _repo_fullname(payload)
pr_number = pr.get("number", 0)
pr_title = pr.get("title", "")
pr_author = pr.get("user", {}).get("login", "unknown")
new_sha = pr.get("head", {}).get("sha", "unknown")[:12]
# 查询最近 review 的提交者
reviewer = await _fetch_latest_reviewer(repo, pr_number)
if not reviewer:
# 没有 review 历史,跳过(opened 事件已经通知过)
logger.debug("No review history for PR #%s, skipping synchronize notification", pr_number)
return
text = render_template("review_updated", {
"repo": repo,
"pr_number": str(pr_number),
"pr_title": pr_title,
"pr_author": pr_author,
"new_sha": new_sha,
"reviewer": reviewer,
})
title = f"PR 更新: {pr_title} ({repo}#{pr_number})"
_send_mail(reviewer, title, text)
async def _handle_issues(payload: Dict[str, Any]) -> None:
"""处理 issues 事件:assigned → 通知被指派人;opened+部署失败 → 通知运维。"""
action = payload.get("action", "")