feat(toolchain): add PR synchronize and review comment notifications
- 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:
@@ -77,7 +77,8 @@ def _is_duplicate(event: str, delivery: str,
|
||||
# 取 body 或 content,优先 body(webhookNotifier 格式)
|
||||
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 → 通知 reviewer;synchronize → 通知 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", "")
|
||||
|
||||
Reference in New Issue
Block a user