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
+34 -19
View File
@@ -35,7 +35,7 @@ from robomp.config import Settings
from robomp.db import Database, issue_key
from robomp.git_ops import DirtyState, inspect_dirty_state
from robomp.github_backend import GitHubBackend
from robomp.github_client import CommentInfo, IssueInfo, RepoInfo
from robomp.github_client import CommentInfo, IssueInfo, PullRequestInfo, RepoInfo
from robomp.host_tools import AbortController, ToolBindings, _git_identity_env
from robomp.natives_cache import NativesCache
from robomp.natives_cache import compute_key as natives_compute_key
@@ -190,6 +190,7 @@ def _build_extra_env(settings: Settings) -> dict[str, str]:
_TERMINAL_TRIAGE_TOOLS: frozenset[str] = frozenset({"gh_open_pr", "mark_unable_to_reproduce", "abort_task"})
_TERMINAL_REVIEW_TOOLS: frozenset[str] = frozenset({"submit_pr_review", "abort_task"})
_PR_REQUIRING_CLASSIFICATIONS: frozenset[str] = frozenset({"bug", "documentation"})
@@ -200,16 +201,13 @@ def _needs_completion_reminder(
bindings: ToolBindings,
tools_called: set[str],
) -> bool:
"""True iff a `triage_issue` turn ended before reaching a terminal tool.
Only enforced for `bug` / `documentation` classifications — `question`,
`enhancement`, `proposal`, `invalid`, `duplicate` terminate on a single
`gh_post_comment` which we can't reliably distinguish from a preamble.
"""
if task_kind != "triage_issue":
return False
"""True iff a task turn ended before reaching its terminal tool."""
if bindings.abort is not None and bindings.abort.triggered:
return False
if task_kind == "review_pr":
return not (tools_called & _TERMINAL_REVIEW_TOOLS)
if task_kind != "triage_issue":
return False
row = inputs.db.get_issue(bindings.issue_key)
if row is None or row.classification not in _PR_REQUIRING_CLASSIFICATIONS:
return False
@@ -278,8 +276,9 @@ def _drive_turn(
needs_completion = _needs_completion_reminder(
task_kind=task_kind, inputs=inputs, bindings=bindings, tools_called=tools_called
)
dirty: DirtyState | None = None
if not needs_completion:
if task_kind == "review_pr":
break
dirty = _probe_workspace_dirty(inputs.workspace, inputs.slot_uid)
if not dirty.is_dirty:
break
@@ -294,7 +293,11 @@ def _drive_turn(
"max": max_reminders,
},
)
reminder = persona.completion_reminder(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
reminder = (
persona.review_completion_reminder(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
if task_kind == "review_pr"
else persona.completion_reminder(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
)
else:
assert dirty is not None
log.warning(
@@ -331,7 +334,7 @@ def _drive_turn(
"tools_called": sorted(tools_called),
},
)
if reminders_used:
if reminders_used and task_kind != "review_pr":
final_dirty = _probe_workspace_dirty(inputs.workspace, inputs.slot_uid)
if final_dirty.is_dirty:
log.warning(
@@ -368,6 +371,7 @@ def _build_prompt(
comment: CommentInfo | None,
pr_number: int | None,
review_payload: dict[str, Any] | None,
pr: PullRequestInfo | None = None,
directive: DirectiveInfo | None = None,
thread: tuple[ThreadMessage, ...] = (),
resuming: bool = False,
@@ -383,6 +387,9 @@ def _build_prompt(
directive=directive,
)
return persona.kickoff(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
if task_kind == "review_pr":
assert pr is not None
return persona.kickoff_pr_review(repo=inputs.repo, pr=pr, workspace=inputs.workspace)
if task_kind == "handle_comment":
assert comment is not None
issue_row = inputs.db.get_issue(issue_key(inputs.repo.full_name, inputs.issue.number))
@@ -503,6 +510,11 @@ def _run_rpc_blocking(
},
)
inputs.db.set_event_model(inputs.delivery_id, chosen_model)
append_system_prompt = (
persona.system_append_pr_review(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
if task_kind == "review_pr"
else persona.system_append(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
)
with RpcClient(
executable=settings.omp_command,
@@ -514,7 +526,7 @@ def _run_rpc_blocking(
model=chosen_model,
provider=settings.provider,
thinking=chosen_thinking if chosen_thinking != "off" else None,
append_system_prompt=persona.system_append(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace),
append_system_prompt=append_system_prompt,
custom_tools=host_tools.build(bindings),
request_timeout=settings.request_timeout_seconds,
startup_timeout=60.0,
@@ -557,13 +569,12 @@ def _run_rpc_blocking(
phases = persona.seed_phases(task_kind)
if phases:
try:
if task_kind == "triage_issue" and not resuming:
# Fresh triage: seed the full plan.
if task_kind in ("triage_issue", "review_pr") and not resuming:
# Fresh kickoff tasks seed the full plan.
client.set_todos(phases)
elif task_kind == "triage_issue":
# Resumed triage: prior phases are intact in the
# JSONL transcript — re-seeding would clobber any
# in-progress task statuses. Trust the loaded state.
elif task_kind in ("triage_issue", "review_pr"):
# Resumed kickoff tasks keep prior todo state from the
# JSONL transcript; re-seeding would clobber progress.
log.info(
"set_todos skipped (resume)",
extra={"issue": bindings.issue_key, "task": task_kind},
@@ -653,10 +664,12 @@ async def run_task(
comment: CommentInfo | None = None,
pr_number: int | None = None,
review_payload: dict[str, Any] | None = None,
pr: PullRequestInfo | None = None,
directive: DirectiveInfo | None = None,
thread: tuple[ThreadMessage, ...] = (),
) -> str | None:
"""Async wrapper that runs the synchronous RPC driver on a worker thread."""
review_mode = task_kind == "review_pr" or inputs.workspace.branch.startswith("review/pr-")
loop = asyncio.get_running_loop()
bindings = ToolBindings(
db=inputs.db,
@@ -671,6 +684,7 @@ async def run_task(
author_email=inputs.settings.git_author_email,
inbound_thread_number=pr_number,
inbound_is_pr=pr_number is not None,
review_mode=review_mode,
slot_uid=inputs.slot_uid,
abort=AbortController(),
)
@@ -681,6 +695,7 @@ async def run_task(
comment=comment,
pr_number=pr_number,
review_payload=review_payload,
pr=pr,
directive=directive,
thread=thread,
resuming=resuming,