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:09 +02:00
parent 04cc014c16
commit b503f7d86b
30 changed files with 2392 additions and 112 deletions
+316 -1
View File
@@ -10,6 +10,7 @@ import asyncio
import json
import logging
import os
import re
import subprocess
import time
from collections.abc import Callable, Mapping
@@ -25,7 +26,7 @@ from robomp.config import Settings
from robomp.db import Database, issue_key
from robomp.git_ops import GitCommandError, HeadDriftError
from robomp.github_backend import GitHubBackend
from robomp.github_client import GitHubError, IssueInfo, RepoInfo
from robomp.github_client import GitHubError, IssueInfo, PullRequestFileInfo, RepoInfo
from robomp.sandbox import (
GitTransport,
Workspace,
@@ -109,6 +110,9 @@ class ToolBindings:
# — the originating issue has already been classified and the PR
# itself does not carry triage labels.
inbound_is_pr: bool = False
# True only for incoming-PR review tasks. Review tools require it; mutating
# branch/PR publication tools reject when it is set.
review_mode: bool = False
slot_uid: int | None = None
# Set by the worker before launching omp. Carries the abort-task signal
# back out to the worker; `None` for unit tests that exercise tools
@@ -514,6 +518,10 @@ def _build_post_comment(bindings: ToolBindings) -> HostTool[Any, Any]:
def _guarded_push_branch(bindings: ToolBindings, args: Mapping[str, Any], tool_name: str, branch: str) -> str:
if bindings.review_mode:
msg = "refusing to push: PR review worktrees are read-only."
_audit(bindings, tool_name, args, error=msg)
_raise_command(msg)
if branch != bindings.workspace.branch:
_raise_command(
f"refusing to push: branch={branch!r} does not match workspace branch {bindings.workspace.branch!r}."
@@ -611,6 +619,10 @@ def _guarded_push_branch(bindings: ToolBindings, args: Mapping[str, Any], tool_n
# ---------- gh_push_branch ----------
def _build_push_branch(bindings: ToolBindings) -> HostTool[Any, Any]:
def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str:
if bindings.review_mode:
msg = "refusing to push: PR review worktrees are read-only."
_audit(bindings, "gh_push_branch", args, error=msg)
_raise_command(msg)
branch = str(args.get("branch") or bindings.workspace.branch)
skip = bool(args.get("skip_checks", False))
# Same gate as gh_open_pr — formatter + check before bytes leave the
@@ -648,6 +660,10 @@ def _build_push_branch(bindings: ToolBindings) -> HostTool[Any, Any]:
# ---------- gh_open_pr ----------
def _build_open_pr(bindings: ToolBindings) -> HostTool[Any, Any]:
def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str:
if bindings.review_mode:
msg = "refusing to open PR: PR review tasks are read-only."
_audit(bindings, "gh_open_pr", args, error=msg)
_raise_command(msg)
title = args.get("title")
body = args.get("body")
if not isinstance(title, str) or not title.strip():
@@ -969,6 +985,301 @@ _PRIMARY_TYPES = ("bug", "enhancement", "question", "proposal", "documentation",
_PRIORITIES = ("prio:p0", "prio:p1", "prio:p2", "prio:p3")
_FUNCTIONAL = ("agent", "tool", "tui", "cli", "prompting", "sdk", "auth", "setup", "ux", "providers")
_PLATFORMS = ("platform:linux", "platform:macos", "platform:windows", "platform:wsl")
_PR_RANKS = ("review:p0", "review:p1", "review:p2", "review:p3")
_PR_TYPES = ("feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "build")
_CLOSING_ISSUE_RE = re.compile(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", re.IGNORECASE)
def _require_review_mode(bindings: ToolBindings, name: str, args: Mapping[str, Any]) -> None:
if bindings.review_mode:
return
msg = f"{name} is only available during incoming PR review tasks."
_audit(bindings, name, args, error=msg)
_raise_command(msg)
def _format_pr_file(file: PullRequestFileInfo) -> str:
return f"- `{file.path}` ({file.status}, +{file.additions}/-{file.deletions})"
def _build_fetch_pr(bindings: ToolBindings) -> HostTool[Any, Any]:
def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str:
_require_review_mode(bindings, "fetch_pr", args)
pr_number = bindings.default_comment_number
try:
pr = _run_coro(bindings.loop, bindings.github.get_pull_request(bindings.repo.full_name, pr_number))
files = _run_coro(bindings.loop, bindings.github.list_pr_files(bindings.repo.full_name, pr_number))
except GitHubError as exc:
_audit(bindings, "fetch_pr", args, error=str(exc))
_raise_command(f"GitHub fetch failed: {exc.status} {exc.message}")
linked = tuple(sorted({int(match.group(1)) for match in _CLOSING_ISSUE_RE.finditer(pr.body)}))
lines = [
f"# {pr.repo}#{pr.number} ({pr.state})",
f"title: {pr.title or '(untitled)'}",
f"author: @{pr.author}",
f"head: {pr.head_repo or pr.repo}:{pr.head_ref}",
f"base: {pr.base_ref}",
f"url: {pr.html_url}",
"",
"## Body",
pr.body.strip() or "(empty)",
"",
"## Linked issues",
", ".join(f"#{n}" for n in linked) if linked else "(none found in PR body)",
"",
f"## Changed files ({len(files)})",
]
lines.extend(_format_pr_file(file) for file in files)
rendered = "\n".join(lines)
_audit(bindings, "fetch_pr", args, result={"files": len(files), "linked_issues": list(linked)})
return rendered
return host_tool(
name="fetch_pr",
description=persona.host_tool_description("fetch_pr"),
parameters={"type": "object", "properties": {}, "additionalProperties": False},
execute=execute,
)
def _build_classify_pr(bindings: ToolBindings) -> HostTool[Any, Any]:
def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str:
_require_review_mode(bindings, "classify_pr", args)
rank = args.get("rank")
if rank not in _PR_RANKS:
msg = f"classify_pr 'rank' must be one of {_PR_RANKS}; got {rank!r}."
_audit(bindings, "classify_pr", args, error=msg)
_raise_command(msg)
pr_type = args.get("type")
if pr_type not in _PR_TYPES:
msg = f"classify_pr 'type' must be one of {_PR_TYPES}; got {pr_type!r}."
_audit(bindings, "classify_pr", args, error=msg)
_raise_command(msg)
rationale = args.get("rationale")
if not isinstance(rationale, str) or not rationale.strip():
msg = "classify_pr requires a one-sentence 'rationale'."
_audit(bindings, "classify_pr", args, error=msg)
_raise_command(msg)
labels: list[str] = ["triaged", str(rank), str(pr_type)]
for area in args.get("area") or ():
if isinstance(area, str) and area in _FUNCTIONAL:
labels.append(area)
provider = args.get("provider")
if isinstance(provider, str) and provider.strip() and provider.startswith("provider:"):
labels.append("providers")
labels.append(provider)
try:
applied = _run_coro(
bindings.loop,
bindings.github.add_issue_labels(bindings.repo.full_name, bindings.default_comment_number, labels),
)
except GitHubError as exc:
_audit(bindings, "classify_pr", args, error=str(exc))
_raise_command(f"GitHub rejected labels: {exc.status} {exc.message}")
bindings.db.set_issue_classification(bindings.issue_key, str(rank))
_audit(
bindings,
"classify_pr",
args,
result={"rank": rank, "type": pr_type, "labels": list(applied), "rationale": rationale},
)
return f"classified PR as {rank}; labels applied: {', '.join(applied)}."
return host_tool(
name="classify_pr",
description=persona.host_tool_description("classify_pr"),
parameters={
"type": "object",
"properties": {
"rank": {
"type": "string",
"enum": list(_PR_RANKS),
"description": persona.host_tool_parameter_description("classify_pr", "rank"),
},
"type": {
"type": "string",
"enum": list(_PR_TYPES),
"description": persona.host_tool_parameter_description("classify_pr", "type"),
},
"area": {
"type": "array",
"items": {"type": "string", "enum": list(_FUNCTIONAL)},
"description": persona.host_tool_parameter_description("classify_pr", "area"),
},
"provider": {
"type": "string",
"description": persona.host_tool_parameter_description("classify_pr", "provider"),
},
"rationale": {
"type": "string",
"description": persona.host_tool_parameter_description("classify_pr", "rationale"),
},
},
"required": ["rank", "type", "rationale"],
"additionalProperties": False,
},
execute=execute,
)
def _review_comment_to_payload(comment: Any) -> dict[str, Any]:
payload: dict[str, Any] = {
"path": comment.path,
"line": comment.line,
"side": comment.side,
"body": comment.body,
}
if comment.start_line is not None:
payload["start_line"] = comment.start_line
if comment.start_side is not None:
payload["start_side"] = comment.start_side
return payload
def _build_pr_review_comment(bindings: ToolBindings) -> HostTool[Any, Any]:
def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str:
_require_review_mode(bindings, "pr_review_comment", args)
path = args.get("path")
line = args.get("line")
body = args.get("body")
if not isinstance(path, str) or not path.strip():
msg = "pr_review_comment requires a non-empty 'path'."
_audit(bindings, "pr_review_comment", args, error=msg)
_raise_command(msg)
if not isinstance(line, int) or line <= 0:
msg = "pr_review_comment requires a positive integer 'line'."
_audit(bindings, "pr_review_comment", args, error=msg)
_raise_command(msg)
if not isinstance(body, str) or not body.strip():
msg = "pr_review_comment requires a non-empty 'body'."
_audit(bindings, "pr_review_comment", args, error=msg)
_raise_command(msg)
side = str(args.get("side") or "RIGHT")
if side not in ("RIGHT", "LEFT"):
msg = "pr_review_comment 'side' must be RIGHT or LEFT."
_audit(bindings, "pr_review_comment", args, error=msg)
_raise_command(msg)
start_line = args.get("start_line")
if start_line is not None and (not isinstance(start_line, int) or start_line <= 0):
msg = "pr_review_comment 'start_line' must be a positive integer when provided."
_audit(bindings, "pr_review_comment", args, error=msg)
_raise_command(msg)
start_side_raw = args.get("start_side")
start_side = str(start_side_raw) if start_side_raw is not None else None
if start_side is not None and start_side not in ("RIGHT", "LEFT"):
msg = "pr_review_comment 'start_side' must be RIGHT or LEFT when provided."
_audit(bindings, "pr_review_comment", args, error=msg)
_raise_command(msg)
staged = bindings.db.stage_review_comment(
issue_key=bindings.issue_key,
path=path.strip(),
line=line,
side=side,
start_line=start_line,
start_side=start_side,
body=body.strip(),
)
count = len(bindings.db.list_staged_review_comments(bindings.issue_key))
_audit(bindings, "pr_review_comment", args, result={"id": staged.id, "staged": count})
return f"staged review comment #{staged.id}; staged_count={count}"
return host_tool(
name="pr_review_comment",
description=persona.host_tool_description("pr_review_comment"),
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": persona.host_tool_parameter_description("pr_review_comment", "path"),
},
"line": {
"type": "integer",
"description": persona.host_tool_parameter_description("pr_review_comment", "line"),
},
"body": {
"type": "string",
"description": persona.host_tool_parameter_description("pr_review_comment", "body"),
},
"side": {
"type": "string",
"enum": ["RIGHT", "LEFT"],
"default": "RIGHT",
"description": persona.host_tool_parameter_description("pr_review_comment", "side"),
},
"start_line": {
"type": "integer",
"description": persona.host_tool_parameter_description("pr_review_comment", "start_line"),
},
"start_side": {
"type": "string",
"enum": ["RIGHT", "LEFT"],
"description": persona.host_tool_parameter_description("pr_review_comment", "start_side"),
},
},
"required": ["path", "line", "body"],
"additionalProperties": False,
},
execute=execute,
)
def _build_submit_pr_review(bindings: ToolBindings) -> HostTool[Any, Any]:
def execute(args: dict[str, Any], _ctx: HostToolContext[Any]) -> str:
_require_review_mode(bindings, "submit_pr_review", args)
body = args.get("body")
if not isinstance(body, str) or not body.strip():
msg = "submit_pr_review requires a non-empty 'body'."
_audit(bindings, "submit_pr_review", args, error=msg)
_raise_command(msg)
staged = bindings.db.list_staged_review_comments(bindings.issue_key)
comments = [_review_comment_to_payload(comment) for comment in staged]
try:
review = _run_coro(
bindings.loop,
bindings.github.submit_pr_review(
repo=bindings.repo.full_name,
pr_number=bindings.default_comment_number,
body=body.strip(),
event="COMMENT",
comments=comments,
),
)
except GitHubError as exc:
_audit(bindings, "submit_pr_review", args, error=str(exc))
_raise_command(f"GitHub rejected PR review: {exc.status} {exc.message}")
cleared = bindings.db.clear_staged_review_comments(bindings.issue_key)
_audit(
bindings,
"submit_pr_review",
args,
result={"review_id": review.id, "comments": len(comments), "cleared": cleared, "event": "COMMENT"},
)
return f"submitted PR review id={review.id}; comments={len(comments)}"
return host_tool(
name="submit_pr_review",
description=persona.host_tool_description("submit_pr_review"),
parameters={
"type": "object",
"properties": {
"body": {
"type": "string",
"description": persona.host_tool_parameter_description("submit_pr_review", "body"),
},
"event": {
"type": "string",
"enum": ["COMMENT"],
"default": "COMMENT",
"description": persona.host_tool_parameter_description("submit_pr_review", "event"),
},
},
"required": ["body"],
"additionalProperties": False,
},
execute=execute,
)
def _build_set_issue_labels(bindings: ToolBindings) -> HostTool[Any, Any]:
@@ -1205,6 +1516,10 @@ def build(bindings: ToolBindings) -> tuple[HostTool[Any, Any], ...]:
return (
_build_classify_issue(bindings),
_build_set_issue_labels(bindings),
_build_fetch_pr(bindings),
_build_classify_pr(bindings),
_build_pr_review_comment(bindings),
_build_submit_pr_review(bindings),
_build_post_comment(bindings),
_build_push_branch(bindings),
_build_open_pr(bindings),