feat(robomp): added incoming PR review feature with classify and submit

- Added `review_pr` task that checks out PR head in a detached worktree, classifies rank/type/area, and posts a batched GitHub review as `event=COMMENT`.
- Added four new host tools: `fetch_pr`, `classify_pr`, `pr_review_comment`, and `submit_pr_review`; review tools self-gate on `review_mode`, push/open-PR tools refuse when `review_mode` is set.
- Added sqlite staging table `pr_review_comments` with `stage_review_comment`, `list_staged_review_comments`, and `clear_staged_review_comments` DAOs.
- Routed `pull_request.opened/reopened/ready_for_review` to `review_pr` and extended `pull_request.closed` cleanup to any tracked PR regardless of author.
This commit is contained in:
can1357
2026-06-02 08:23:04 +02:00
parent 04cc014c16
commit b503f7d86b
30 changed files with 2392 additions and 112 deletions
+44 -21
View File
@@ -134,6 +134,7 @@ def route(
maintainers: frozenset[str] = frozenset(),
reviewer_bots: frozenset[str] = frozenset(),
resolve_issue_from_pr: PrIssueResolver = None,
pr_review_enabled: bool = True,
) -> RouteDecision:
"""Decide whether and how to handle a webhook event.
@@ -225,23 +226,26 @@ def route(
if not isinstance(number, int):
return RouteDecision("skip", None, repo, None, "comment missing issue number")
if "pull_request" in issue:
# Conversation comment on a PR. The PR number lives at issue.number
# on this payload type. Prefer the originating issue key when the
# DB has it, but do not drop bot-authored follow-ups just because
# the PR mapping was lost; the worker can recover from the PR
# branch or handle the PR directly.
# Conversation comments on incoming contributor PRs are intentionally
# ignored for now: the one-shot review runs on open, and re-review
# directives are not wired yet. Only bot-authored PRs resume a live
# amend-and-push workflow.
key = _resolve_pr_key(number)
login, assoc = _submitter_info(comment)
return RouteDecision(
"queue",
"handle_pr_conversation",
repo,
key,
f"issue_comment.created on PR #{number}",
submitter=login,
association=assoc,
**_directive_kwargs(comment, login, assoc),
)
issue_user_raw = issue.get("user")
issue_user = issue_user_raw if isinstance(issue_user_raw, Mapping) else {}
if str(issue_user.get("login") or "") == bot_login:
return RouteDecision(
"queue",
"handle_pr_conversation",
repo,
key,
f"issue_comment.created on PR #{number}",
submitter=login,
association=assoc,
**_directive_kwargs(comment, login, assoc),
)
return RouteDecision("skip", None, repo, issue_key(repo, number), "incoming PR comments ignored")
key = issue_key(repo, number)
login, assoc = _submitter_info(comment)
return RouteDecision(
@@ -255,6 +259,29 @@ def route(
**_directive_kwargs(comment, login, assoc),
)
if event_type == "pull_request" and action in ("opened", "reopened", "ready_for_review"):
if not pr_review_enabled:
return RouteDecision("skip", None, repo, None, "PR review disabled")
pr = payload.get("pull_request") or {}
if bool(pr.get("draft")):
return RouteDecision("skip", None, repo, None, "draft PR")
pr_user = pr.get("user") or {}
if _is_bot_account(pr_user, bot_login):
return RouteDecision("skip", None, repo, None, "bot-authored PR")
number = pr.get("number")
if not isinstance(number, int):
return RouteDecision("skip", None, repo, None, "PR missing number")
login, assoc = _submitter_info(pr)
return RouteDecision(
"queue",
"review_pr",
repo,
issue_key(repo, number),
f"pull_request.{action}",
submitter=login,
association=assoc,
)
if event_type == "pull_request_review_comment" and action == "created":
comment = payload.get("comment") or {}
rb_login = _reviewer_bot_login(comment.get("user"))
@@ -282,15 +309,11 @@ def route(
if event_type == "pull_request" and action == "closed":
pr = payload.get("pull_request") or {}
pr_user = pr.get("user") or {}
if str(pr_user.get("login") or "") != bot_login:
return RouteDecision("skip", None, repo, None, "PR not bot-authored")
if not bool(pr.get("merged")):
return RouteDecision("skip", None, repo, None, "PR closed without merge")
number = pr.get("number")
if not isinstance(number, int):
return RouteDecision("skip", None, repo, None, "PR missing number")
return RouteDecision("queue", "cleanup_workspace", repo, _resolve_pr_key(number), "pull_request.merged")
reason = "pull_request.merged" if bool(pr.get("merged")) else "pull_request.closed"
return RouteDecision("queue", "cleanup_workspace", repo, _resolve_pr_key(number), reason)
return RouteDecision("skip", None, repo, None, f"{event_type}.{action} not handled")