19 KiB
Handoff: incoming-PR review feature
Wire robomp to review pull requests opened by contributors (and other bots), in two phases: (1) classify + rank, (2) a real line-by-line review posted as one GitHub review. robomp never merges, closes, approves, or pushes — the rank label is the verdict; the maintainer acts on it.
Confirmed decisions:
- COMMENT-only.
submit_pr_reviewalways usesevent="COMMENT". NeverAPPROVE/REQUEST_CHANGES(those gate merge — the maintainer's call). - SQLite staging. Inline comments are staged in a sqlite table, flushed in one review.
Survives
--continueresume; honours "DB is the only source of truth, in-memory state is just_inflight." - Reuse the issue isolation verbatim. The PR head is checked out into a per-PR worktree (clone pool + slot uid + natives cache + scrubbed env) before the agent starts, and that worktree is the agent's cwd. Review is read-only on that checkout.
The agent prompt already exists: src/prompts/pr_review_rubric.md (rename to
kickoff_pr_review.md — see §5). Everything below is the wiring around it.
1. How the existing flows work (the substrate to mirror)
End-to-end, every task today follows the same spine:
GitHub webhook
└─ server.py POST /webhook/github (HMAC verify; 401 on bad sig)
└─ github_events.route(event_type, payload, …) → RouteDecision(queue|skip, task, …)
└─ db.record_event(...) INSERT OR IGNORE on X-GitHub-Delivery → 202
WorkerPool._dispatch_loop (BEGIN IMMEDIATE claim; _inflight set keyed by (owner,repo,number))
└─ WorkerPool._dispatch(row) re-derives handler from (event_type, action)
└─ tasks.<entry>(settings, db, github, sandbox, git_transport, payload, delivery_id, …)
├─ resolve RepoInfo + IssueInfo (PRs are issues)
├─ sandbox.ensure_workspace(...) → per-issue worktree (clone pool, slot, natives)
├─ db.upsert_issue(...) → state + branch + session_dir
└─ worker.run_task(task_kind=..., inputs=TaskInputs, …)
├─ ToolBindings(inbound_thread_number=pr_number, inbound_is_pr=…)
├─ _build_prompt(task_kind, …) → persona.<prompt>(...)
├─ RpcClient(omp --mode rpc, cwd=worktree, custom_tools=host_tools.build(bindings))
└─ _drive_turn(...) → completion/dirty reminders until terminal tool / clean
Existing task kinds and their analogues for us:
| Task | Trigger (route) |
Workspace | Terminal action | Notes |
|---|---|---|---|---|
triage_issue |
issues.opened |
fresh farm/<hex>/<slug> worktree |
gh_open_pr / mark_unable_to_reproduce / abort_task |
The fresh-entry template for review_pr. |
handle_comment |
issue_comment.created on an issue |
resume existing | one gh_post_comment |
|
handle_pr_conversation |
issue_comment.created on a PR |
resume bot-PR branch | gh_post_comment / push |
bot-owned PRs only. |
handle_review |
pull_request_review_comment.created on a bot-authored PR |
resume via existing_branch=pr.head_ref |
reply / push | The PR-context template — shows ensure_workspace(existing_branch=…) and inbound_is_pr. |
cleanup_workspace |
issues.closed / bot pull_request.merged |
removes worktree | — |
Two facts that shape the wiring:
route()andWorkerPool._dispatch()both branch on(event_type, action).routedecides queue/skip + carriessubmitter/directive;_dispatchre-derives the handler. A new task kind must be added in both.host_tools.build(bindings)returns an identical tuple for every task kind (to keep the LLM prompt cache warm); tools self-gate at execution time (e.g.classify_issuerejects whenbindings.inbound_is_pr). We follow the same pattern: add the new tools tobuild()unconditionally and gate them on areview_modeflag.
2. Routing (src/github_events.py)
2a. New entry: incoming PR opened
Add a pull_request branch before the existing pull_request/closed block. Trigger a
one-shot review on opened, reopened, and ready_for_review; skip everything else
(notably synchronize already falls through to the final skip — keep it that way: do not
re-review on new commits).
if event_type == "pull_request" and action in ("opened", "reopened", "ready_for_review"):
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") # our own farm PRs
number = pr.get("number")
if not isinstance(number, int):
return RouteDecision("skip", None, repo, None, "PR missing number")
login, assoc = _submitter_info(pr) # PR author = rate-limit subject
return RouteDecision("queue", "review_pr", repo, issue_key(repo, number),
f"pull_request.{action}", submitter=login, association=assoc)
Use the PR's own key (issue_key(repo, number)), not _resolve_pr_key — an incoming PR
has no originating bot issue.
2b. Gate incoming-PR comments ("don't run on comments unless I ask")
Today issue_comment.created on any PR queues handle_pr_conversation. For incoming
(non-bot) PRs that would make the bot respond to every comment. Change the PR branch of the
issue_comment handler to:
- PR author is the bot →
handle_pr_conversation(unchanged). - PR author is not the bot → skip, unless
_directive_kwargs(...)is non-empty (a maintainer@botmention or a configured reviewer bot). A directive routes to the existing directive path; only an explicit "re-review" directive re-runs the review.
The PR author is on payload.issue.user.login for issue_comment events. synchronize,
edited, etc. need no change (they already skip).
2c. Cleanup for incoming PRs
pull_request.closed currently requires a bot-authored, merged PR. Incoming-PR review
worktrees would otherwise leak. Extend the close branch (or add a TTL sweep) so an incoming
PR's worktree is GC'd on close. Minimal: when the PR has a workspace row, route
pull_request.closed → cleanup_workspace regardless of author/merge.
3. Dispatch (src/queue.py _dispatch)
Add a branch mirroring triage_issue:
elif event == "pull_request" and action in ("opened", "reopened", "ready_for_review"):
await tasks.review_pr(
settings=self.settings, db=self.db, github=self.github,
sandbox=self.sandbox, git_transport=self.git_transport,
payload=row.payload, delivery_id=row.delivery_id,
attempts=row.attempts, slot_uid=slot_uid,
)
Idempotency: record_event dedups on delivery id and _inflight serializes per
(owner,repo,number). Add one guard in tasks.review_pr: if the PR already carries a
triaged/review:* label, skip the re-review (a reopened shouldn't redo work) unless a
directive forces it.
4. Task dispatcher (src/tasks.py review_pr)
New entry point — structurally triage_issue (fresh worktree) crossed with handle_review
(PR context). Key differences: it checks out the PR head, and it never opens an issue row
for an originating issue (the PR is the unit).
async def review_pr(*, settings, db, github, sandbox, git_transport,
payload, delivery_id, attempts=0, slot_uid=None) -> None:
pr_node = payload.get("pull_request") or {}
pr_number = int(pr_node.get("number") or 0)
repo_full = str((payload.get("repository") or {}).get("full_name") or "")
if pr_number <= 0 or not repo_full:
return
repo = await github.get_repo(repo_full)
issue = await github.get_issue(repo_full, pr_number) # PR-as-issue → title/body/labels
pr = await github.get_pull_request(repo_full, pr_number)
# idempotency: already triaged? bail (see §3)
key = issue_key(repo_full, pr_number)
db.upsert_issue(key=key, repo=repo_full, number=pr_number, state="reviewing", pr_number=pr_number)
workspace = sandbox.ensure_workspace(
repo=repo.full_name, number=pr_number, title=issue.title,
clone_url=repo.clone_url, default_branch=repo.default_branch,
pr_head=pr_number, # ← NEW: check out the PR head (see §6)
author_name=settings.resolved_author_name, author_email=settings.git_author_email,
slot_uid=slot_uid,
)
db.upsert_issue(key=key, repo=repo_full, number=pr_number, state="reviewing",
branch=workspace.branch, session_dir=str(workspace.session_dir), pr_number=pr_number)
inputs = TaskInputs(settings=settings, db=db, github=github, git_transport=git_transport,
repo=repo, issue=issue, workspace=workspace, delivery_id=delivery_id,
attempts=attempts, slot_uid=slot_uid, natives_cache=sandbox.natives_cache)
await run_task(task_kind="review_pr", inputs=inputs, pr_number=pr_number)
run_task(..., pr_number=pr_number) makes ToolBindings.inbound_is_pr=True and points the
comment tools at the PR thread (existing behavior). Add review_pr to tasks.__all__.
5. Prompt + persona (src/persona.py, src/prompts/)
-
Rename
src/prompts/pr_review_rubric.md→src/prompts/kickoff_pr_review.md(it's the full kickoff now, not just a rubric). -
Add the loader, mirroring
kickoff:def kickoff_pr_review(*, repo: RepoInfo, pr: PullRequestInfo, workspace: Workspace) -> str: return render(_load("kickoff_pr_review.md"), {"repo": repo, "pr": pr, "workspace": workspace})The template references
{{repo.*}},{{pr.number|author|head_ref|base_ref|head_repo|html_url}},{{workspace.branch}}. Title/body/diff come from thefetch_prtool, not template vars (PullRequestInfohas no title/body)._lookupreturns""for any missing field — safe. -
_build_prompt(worker.py): add atask_kind == "review_pr"branch callingpersona.kickoff_pr_review(repo=inputs.repo, pr=<pr>, workspace=inputs.workspace). Theprobject must reach_build_prompt— simplest is to add an optionalpr: PullRequestInfo | Noneparam torun_task/_build_prompt(parallel tocomment/review_payload), or rebuild it frominputs.issue(number/author) + aget_pull_requestcall inside the branch. -
todo_phases.toml: add areview_prtable (Phase 0 orient / Phase 1 classify / Phase 2 review) soseed_phases("review_pr")seeds the todo list, liketriage_issue. -
host_tools.toml: add descriptions for the four new tools (see §7).
6. Sandbox: check out the PR head (src/sandbox.py)
This is the load-bearing isolation change. Reuse the entire worktree machinery; only the
checkout source differs. The PR head may live on a fork, so it is fetched via
refs/pull/<n>/head on the base repo's remote (not a branch on origin).
GitTransportprotocol — add:def fetch_pr_head(self, *, repo: str, pool_dir: Path, pr_number: int) -> None: ...LocalGitTransport:git fetch origin pull/<n>/head(PAT injected per-call, asfetch_base_refdoes).ProxyGitTransport: add the matching gh-proxy git op (mirror itsfetch_base_refpath over the HMAC channel + a proxy-server handler).ensure_workspace— addpr_head: int | None = None. When set, in thenot repo_existsbranch:Detached HEAD (never a pushable branch — review is read-only). Setself.transport.fetch_pr_head(repo=repo, pool_dir=pool, pr_number=pr_head) _run(["git", "worktree", "add", "--detach", str(repo_dir), "FETCH_HEAD"], cwd=pool)workspace.branch = f"review/pr-{pr_head}"for bookkeeping/logging only. Everything after (slot chown,_share_git_metadata_with_slots,_provision_runtime_dirs, natives-cache hardlink, identity config) runs unchanged, so the review worktree gets the same isolation and the warm native cache as a fix worktree (bun check/lsp stay fast).
Result: the agent's cwd is the PR head, fully isolated, read-only. No credentialed push remote is configured for review worktrees.
7. Host tools (src/host_tools.py)
Add a review_mode: bool = False field to ToolBindings; set it from run_task
(review_mode = task_kind == "review_pr"). Self-gating pattern (consistent with how
classify_issue gates on inbound_is_pr):
- New review tools require
review_mode→ reject otherwise. gh_push_branch/gh_open_prrefuse whenreview_mode(read-only review; never push to a contributor's branch).
Four new tools, registered unconditionally in build():
| Tool | Params | Behavior | Audit |
|---|---|---|---|
fetch_pr |
— (defaults to inbound PR) | get_pull_request + list_pr_files; returns title, body, Fixes #N links, changed-file list (path/status/+−). The premise read. |
yes |
classify_pr |
rank(req review:p0..p3), type(one of _PR_TYPES), area[](⊆ _FUNCTIONAL), provider?, rationale |
Validate (drop unknowns silently, like classify_issue); github.add_issue_labels(repo, pr.number, ["triaged", rank, type, *area, provider?]) (issues-labels API works on PRs); persist rank in the issue row. |
yes |
pr_review_comment |
path(req), line(req int), body(req), side="RIGHT", start_line?, start_side? |
Stage only — append to sqlite (§9). Validate path/line/body. Return staged count. No GitHub call. | yes |
submit_pr_review |
body(req), event="COMMENT" (forced) |
Read staged rows → github.submit_pr_review(repo, pr.number, body, "COMMENT", comments) → clear_staged_review_comments on success. |
yes |
New allowlists next to the existing ones:
_PR_RANKS = ("review:p0", "review:p1", "review:p2", "review:p3")
_PR_TYPES = ("feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "build")
# area reuses _FUNCTIONAL; provider:<name> + _PLATFORMS as for classify_issue
classify_pr mirrors _build_classify_issue (validation + label apply + persist + audit).
submit_pr_review clears the buffer only after a 2xx so a failed post is retryable.
8. Backend (github_backend.py + github_client.py + proxy_client.py + proxy/server.py)
PullRequestInfo: addtitle: str = "",body: str = "". Populate in_pr_from_payload(REST/pulls/{n}carries both) and proxy_pr_from.GitHubBackendprotocol + both impls:list_pr_files(repo, pr_number) -> list[PullRequestFileInfo]→GET /pulls/{n}/files(new small frozen dataclass:path,status,additions,deletions).submit_pr_review(*, repo, pr_number, body, event, comments) -> PullRequestReviewInfo→POST /pulls/{n}/reviewswithcomments=[{path, line, side, body, start_line?, start_side?}].
- gh-proxy mode (
proxy_client.py+src/proxy/server.py): add/gh/v1/pr_files(GET) and/gh/v1/submit_pr_review(POST) endpoints + client wrappers. HMAC signing is generic — no protocol change. Validate inputs server-side with the existing_require_*helpers.
9. DB (src/db.py)
One staging table (schema block near events/issues/tool_calls):
CREATE TABLE IF NOT EXISTS pr_review_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_key TEXT NOT NULL, -- repo__owner#<pr_number>
path TEXT NOT NULL,
line INTEGER NOT NULL,
side TEXT NOT NULL DEFAULT 'RIGHT',
start_line INTEGER,
start_side TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pr_review_comments_key ON pr_review_comments(issue_key);
DAOs (thread-safe via the existing _lock): stage_review_comment(...),
list_staged_review_comments(issue_key) -> list[...], clear_staged_review_comments(issue_key).
Rank persistence reuses the issues row (classification/a new pr_rank column) keyed by the
PR's issue_key.
10. Worker completion gate (src/worker.py)
_needs_completion_reminder: extend sotask_kind == "review_pr"reminds untilsubmit_pr_reviewis intools_called(the terminal action), mirroring the_TERMINAL_TRIAGE_TOOLSlogic. Add a_TERMINAL_REVIEW_TOOLS = {"submit_pr_review", "abort_task"}. Review worktrees are read-only, so the dirty-state reminder is irrelevant — skip it forreview_pr(or it'll be clean anyway).run_task: threadreview_mode/prthrough toToolBindings/_build_prompt(§5, §7).
11. Config (src/config.py) — optional
Add pr_review_enabled: bool = True (ROBOMP_PR_REVIEW_ENABLED) so the whole flow can be
killed without a redeploy; check it in route()'s new branch. Reuse the existing
repo_allowlist, maintainers, reviewer_bots. No new auth.
12. Routing truth table
| Event | Condition | Result |
|---|---|---|
pull_request.opened / reopened / ready_for_review |
non-draft, author ≠ bot, allowlisted, enabled | review_pr |
pull_request.opened |
draft / bot-authored | skip |
pull_request.synchronize (new commits) |
— | skip (no re-review) |
pull_request.edited / others |
— | skip |
issue_comment.created on incoming PR |
not a directive | skip |
issue_comment.created on incoming PR |
maintainer @bot / reviewer bot |
directive path (may re-review) |
issue_comment.created on bot PR |
— | handle_pr_conversation (unchanged) |
pull_request.closed |
has review workspace | cleanup_workspace |
13. Test plan (tests/, pytest, httpx.MockTransport)
Mirror existing test style; assert observable contracts, never internals.
- Routing (
test_github_events.py):pull_request.opened→review_pr; draft/bot/non-allowlist → skip;synchronize→ skip; incoming-PR comment → skip unless directive. - classify_pr (
test_host_tools.py): happy path appliestriaged+review:pN+type+area (assert the labels in the mockedadd_issue_labelscall); bad rank → validation error; unknown area dropped silently. - Staging + submit:
pr_review_commentwrites rows (assert via DB);submit_pr_reviewposts one review with all staged comments +event="COMMENT"(assert the mocked POST body) and clears the buffer; second submit with empty buffer posts summary-only / no-ops. - review_mode gating:
gh_push_branch/gh_open_prrefuse underreview_mode; review tools refuse outside it. - Sandbox (
test_sandbox.py, real local bare repo as upstream):pr_headcheckout yields a detached worktree at the PR head commit; no push remote configured. - Completion gate: a
review_prturn ending beforesubmit_pr_reviewtriggers the reminder.
Do not enable the integration smoke (ROBOMP_INTEGRATION=1) in the default suite.
14. Open questions for @can1357
- Rank label namespace:
review:p0..p3(proposed, avoids colliding with issueprio:p0..p3) — or reuseprio:? These must exist (or be auto-creatable) as repo labels. typelabels: introducefeat/fix/docs/… as bare labels, or namespacetype:feat? The repo's current label set should be checked beforeclassify_prwrites them.- Re-review trigger phrasing: which directive text re-runs Phase 2 vs. just answers a question? (Routed through the existing directive path.)
- Cleanup: GC incoming-PR review worktrees on
pull_request.closed(any author), or a TTL sweep? (§2c.)