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:
@@ -204,6 +204,36 @@ def test_log_tool_call(db: Database) -> None:
|
||||
assert row_id > 0
|
||||
|
||||
|
||||
def test_pr_review_comment_staging_round_trip(db: Database) -> None:
|
||||
first = db.stage_review_comment(
|
||||
issue_key="octo/widget#9",
|
||||
path="src/app.py",
|
||||
line=12,
|
||||
side="RIGHT",
|
||||
start_line=10,
|
||||
start_side="RIGHT",
|
||||
body="blocking finding",
|
||||
)
|
||||
db.stage_review_comment(
|
||||
issue_key="octo/widget#9",
|
||||
path="src/other.py",
|
||||
line=3,
|
||||
body="nit",
|
||||
)
|
||||
db.stage_review_comment(issue_key="octo/widget#10", path="x.py", line=1, body="other")
|
||||
|
||||
rows = db.list_staged_review_comments("octo/widget#9")
|
||||
assert [row.id for row in rows] == [first.id, first.id + 1]
|
||||
assert rows[0].path == "src/app.py"
|
||||
assert rows[0].start_line == 10
|
||||
assert rows[0].start_side == "RIGHT"
|
||||
assert rows[1].side == "RIGHT"
|
||||
|
||||
assert db.clear_staged_review_comments("octo/widget#9") == 2
|
||||
assert db.list_staged_review_comments("octo/widget#9") == []
|
||||
assert len(db.list_staged_review_comments("octo/widget#10")) == 1
|
||||
|
||||
|
||||
def test_processed_issue_keys_returns_only_known(db: Database) -> None:
|
||||
db.upsert_issue(key=issue_key("octo/widget", 1), repo="octo/widget", number=1, state="new")
|
||||
db.upsert_issue(key=issue_key("octo/widget", 2), repo="octo/widget", number=2, state="reproducing")
|
||||
|
||||
@@ -109,6 +109,117 @@ def test_get_pull_request_parses_head_repo_and_author() -> None:
|
||||
assert pr.author == "robomp-bot"
|
||||
|
||||
|
||||
def test_get_pull_request_parses_title_and_body() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/repos/octo/widget/pulls/9"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 9,
|
||||
"html_url": "https://github.com/octo/widget/pull/9",
|
||||
"title": "Fix crash",
|
||||
"body": "Fixes #1",
|
||||
"head": {"ref": "fix", "repo": {"full_name": "fork/widget"}},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
},
|
||||
)
|
||||
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
pr = _run_async(client.get_pull_request("octo/widget", 9))
|
||||
assert pr.title == "Fix crash"
|
||||
assert pr.body == "Fixes #1"
|
||||
|
||||
|
||||
def test_list_pr_files_parses_changed_file_summary() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/repos/octo/widget/pulls/9/files"
|
||||
assert request.url.params.get("per_page") == "100"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"filename": "src/app.py", "status": "modified", "additions": 5, "deletions": 2}],
|
||||
)
|
||||
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
files = _run_async(client.list_pr_files("octo/widget", 9))
|
||||
assert len(files) == 1
|
||||
assert files[0].path == "src/app.py"
|
||||
assert files[0].additions == 5
|
||||
assert files[0].deletions == 2
|
||||
|
||||
|
||||
def test_list_pr_files_paginates_past_first_page() -> None:
|
||||
seen_pages: list[str | None] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/repos/octo/widget/pulls/9/files"
|
||||
page = request.url.params.get("page")
|
||||
seen_pages.append(page)
|
||||
if page == "1":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"filename": f"src/file-{idx}.py",
|
||||
"status": "modified",
|
||||
"additions": 1,
|
||||
"deletions": 0,
|
||||
}
|
||||
for idx in range(100)
|
||||
],
|
||||
)
|
||||
assert page == "2"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"filename": "src/final.py", "status": "added", "additions": 2, "deletions": 0}],
|
||||
)
|
||||
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
files = _run_async(client.list_pr_files("octo/widget", 9))
|
||||
assert seen_pages == ["1", "2"]
|
||||
assert len(files) == 101
|
||||
assert files[-1].path == "src/final.py"
|
||||
|
||||
|
||||
def test_submit_pr_review_posts_comment_event_and_inline_comments() -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
|
||||
captured["path"] = request.url.path
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 44,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": "summary",
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "t",
|
||||
},
|
||||
)
|
||||
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
review = _run_async(
|
||||
client.submit_pr_review(
|
||||
repo="octo/widget",
|
||||
pr_number=9,
|
||||
body="summary",
|
||||
event="COMMENT",
|
||||
comments=[{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}],
|
||||
)
|
||||
)
|
||||
assert review.id == 44
|
||||
assert captured["path"] == "/repos/octo/widget/pulls/9/reviews"
|
||||
assert captured["body"] == {
|
||||
"body": "summary",
|
||||
"event": "COMMENT",
|
||||
"comments": [{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}],
|
||||
}
|
||||
|
||||
|
||||
def test_204_no_content_returns_none() -> None:
|
||||
transport = httpx.MockTransport(lambda r: httpx.Response(204))
|
||||
client = GitHubClient("tok", transport=transport)
|
||||
|
||||
@@ -131,7 +131,7 @@ def test_route_pr_conversation_uses_handle_pr_conversation() -> None:
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "looks good"},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"issue": {"number": 9, "user": {"login": BOT}, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
@@ -155,7 +155,7 @@ def test_route_pr_conversation_uses_resolver_for_inflight_key() -> None:
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "looks good"},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"issue": {"number": 9, "user": {"login": BOT}, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
@@ -175,7 +175,7 @@ def test_route_pr_conversation_falls_back_to_pr_key_when_resolver_misses() -> No
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "hi"},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"issue": {"number": 9, "user": {"login": BOT}, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
@@ -188,6 +188,98 @@ def test_route_pr_conversation_falls_back_to_pr_key_when_resolver_misses() -> No
|
||||
assert decision.issue_key == "octo/widget#9"
|
||||
|
||||
|
||||
def test_route_incoming_pr_opened_queues_review_pr() -> None:
|
||||
decision = route(
|
||||
"pull_request",
|
||||
{
|
||||
"action": "opened",
|
||||
"pull_request": {
|
||||
"number": 9,
|
||||
"draft": False,
|
||||
"user": {"login": "alice", "type": "User"},
|
||||
"author_association": "CONTRIBUTOR",
|
||||
},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "review_pr"
|
||||
assert decision.issue_key == "octo/widget#9"
|
||||
assert decision.submitter == "alice"
|
||||
assert decision.association == "CONTRIBUTOR"
|
||||
|
||||
|
||||
def test_route_incoming_pr_opened_skips_draft_bot_and_disabled() -> None:
|
||||
payload = {
|
||||
"action": "opened",
|
||||
"pull_request": {"number": 9, "draft": True, "user": {"login": "alice", "type": "User"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
}
|
||||
assert not route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT).should_queue
|
||||
|
||||
payload["pull_request"]["draft"] = False # type: ignore[index]
|
||||
payload["pull_request"]["user"] = {"login": BOT, "type": "Bot"} # type: ignore[index]
|
||||
assert not route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT).should_queue
|
||||
|
||||
payload["pull_request"]["user"] = {"login": "alice", "type": "User"} # type: ignore[index]
|
||||
disabled = route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT, pr_review_enabled=False)
|
||||
assert not disabled.should_queue
|
||||
assert "disabled" in disabled.reason
|
||||
|
||||
|
||||
def test_route_pull_request_synchronize_stays_skipped() -> None:
|
||||
decision = route(
|
||||
"pull_request",
|
||||
{
|
||||
"action": "synchronize",
|
||||
"pull_request": {"number": 9, "user": {"login": "alice"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not decision.should_queue
|
||||
|
||||
|
||||
def test_route_incoming_pr_comment_skips() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "ping"},
|
||||
"issue": {"number": 9, "user": {"login": "contributor"}, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not decision.should_queue
|
||||
assert "incoming PR comments ignored" == decision.reason
|
||||
|
||||
|
||||
def test_route_incoming_pr_comment_with_maintainer_mention_still_skips() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "can1357"},
|
||||
"author_association": "OWNER",
|
||||
"body": "@robomp-bot please re-review",
|
||||
},
|
||||
"issue": {"number": 9, "user": {"login": "contributor"}, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not decision.should_queue
|
||||
assert decision.issue_key == "octo/widget#9"
|
||||
assert decision.reason == "incoming PR comments ignored"
|
||||
|
||||
|
||||
def test_route_review_only_for_bot_authored_pr() -> None:
|
||||
decision = route(
|
||||
"pull_request_review_comment",
|
||||
@@ -238,10 +330,10 @@ def test_route_review_comment_falls_back_to_pr_key_when_resolver_misses() -> Non
|
||||
assert decision.issue_key == "octo/widget#9"
|
||||
|
||||
|
||||
def test_route_pr_closed_only_when_merged_by_bot() -> None:
|
||||
def test_route_pr_closed_cleans_up_any_tracked_pr() -> None:
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"pull_request": {"number": 9, "user": {"login": BOT}, "merged": True},
|
||||
"pull_request": {"number": 9, "user": {"login": "alice"}, "merged": False},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
}
|
||||
decision = route(
|
||||
@@ -254,21 +346,21 @@ def test_route_pr_closed_only_when_merged_by_bot() -> None:
|
||||
assert decision.should_queue
|
||||
assert decision.task == "cleanup_workspace"
|
||||
assert decision.issue_key == "octo/widget#42"
|
||||
assert decision.reason == "pull_request.closed"
|
||||
|
||||
fallback = route(
|
||||
payload["pull_request"]["merged"] = True # type: ignore[index]
|
||||
merged = route(
|
||||
"pull_request",
|
||||
payload,
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: None,
|
||||
)
|
||||
assert fallback.should_queue
|
||||
assert fallback.task == "cleanup_workspace"
|
||||
assert fallback.issue_key == "octo/widget#9"
|
||||
assert fallback.submitter is None
|
||||
|
||||
payload["pull_request"]["merged"] = False # type: ignore[index]
|
||||
assert not route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT).should_queue
|
||||
assert merged.should_queue
|
||||
assert merged.task == "cleanup_workspace"
|
||||
assert merged.issue_key == "octo/widget#9"
|
||||
assert merged.reason == "pull_request.merged"
|
||||
assert merged.submitter is None
|
||||
|
||||
|
||||
def test_route_skips_pull_request_issues_event() -> None:
|
||||
@@ -537,7 +629,7 @@ def test_route_directive_unset_for_maintainer_without_mention() -> None:
|
||||
assert decision.directive is False
|
||||
|
||||
|
||||
def test_route_directive_set_on_pr_conversation() -> None:
|
||||
def test_route_directive_on_incoming_pr_conversation_is_ignored() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
@@ -554,10 +646,8 @@ def test_route_directive_set_on_pr_conversation() -> None:
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_pr_conversation"
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "change the indentation in foo.py"
|
||||
assert not decision.should_queue
|
||||
assert decision.reason == "incoming PR comments ignored"
|
||||
|
||||
|
||||
def test_route_directive_set_on_review_comment() -> None:
|
||||
@@ -586,7 +676,7 @@ def test_route_directive_set_on_review_comment() -> None:
|
||||
# ---------- reviewer bots ----------
|
||||
|
||||
|
||||
def test_route_reviewer_bot_comment_is_directive_without_mention() -> None:
|
||||
def test_route_reviewer_bot_comment_on_incoming_pr_is_ignored() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
@@ -603,11 +693,8 @@ def test_route_reviewer_bot_comment_is_directive_without_mention() -> None:
|
||||
reviewer_bots=frozenset({"chatgpt-codex-connector"}),
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_pr_conversation"
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "Found two issues in the diff: ..."
|
||||
assert decision.directive_author == "chatgpt-codex-connector"
|
||||
assert not decision.should_queue
|
||||
assert decision.reason == "incoming PR comments ignored"
|
||||
|
||||
|
||||
def test_route_reviewer_bot_review_comment_is_directive() -> None:
|
||||
@@ -651,16 +738,16 @@ def test_route_random_bot_still_skipped_when_not_in_reviewer_list() -> None:
|
||||
assert "bot" in decision.reason
|
||||
|
||||
|
||||
def test_route_reviewer_bot_login_case_insensitive() -> None:
|
||||
def test_route_reviewer_bot_login_case_insensitive_for_review_comments() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
"pull_request_review_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "ChatGPT-Codex-Connector", "type": "Bot"},
|
||||
"body": "feedback",
|
||||
},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"pull_request": {"number": 9, "user": {"login": BOT}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
@@ -693,16 +780,16 @@ def test_route_directive_strips_pragmas_from_maintainer_comment() -> None:
|
||||
assert decision.directive_pragmas == (("model", "gpt"), ("thinking", "low"))
|
||||
|
||||
|
||||
def test_route_directive_strips_pragmas_from_reviewer_bot_comment() -> None:
|
||||
def test_route_directive_strips_pragmas_from_reviewer_bot_review_comment() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
"pull_request_review_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "chatgpt-codex-connector", "type": "Bot"},
|
||||
"body": "/model claude\nLeak in foo()",
|
||||
},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"pull_request": {"number": 9, "user": {"login": BOT}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
|
||||
@@ -684,6 +684,264 @@ def _pr_bindings(
|
||||
return bindings, loop, thread
|
||||
|
||||
|
||||
def _review_bindings(
|
||||
db: Database, tmp_path: Path, transport: httpx.MockTransport
|
||||
) -> tuple[ToolBindings, asyncio.AbstractEventLoop, threading.Thread]:
|
||||
github = GitHubClient("token", transport=transport)
|
||||
loop, thread = _make_loop_in_background()
|
||||
issue = IssueInfo(
|
||||
repo="octo/widget",
|
||||
number=99,
|
||||
title="contributor PR",
|
||||
body="body",
|
||||
state="open",
|
||||
author="alice",
|
||||
labels=(),
|
||||
is_pull_request=True,
|
||||
)
|
||||
workspace = _stub_workspace(tmp_path)
|
||||
workspace.issue_number = 99
|
||||
bindings = ToolBindings(
|
||||
db=db,
|
||||
github=github,
|
||||
git_transport=LocalGitTransport(token=None),
|
||||
repo=_stub_repo(),
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
loop=loop,
|
||||
author_name="robomp-bot",
|
||||
author_email="robomp-bot@example.invalid",
|
||||
inbound_thread_number=99,
|
||||
inbound_is_pr=True,
|
||||
review_mode=True,
|
||||
)
|
||||
db.upsert_issue(
|
||||
key=bindings.issue_key,
|
||||
repo="octo/widget",
|
||||
number=99,
|
||||
state="reviewing",
|
||||
branch=bindings.workspace.branch,
|
||||
session_dir=str(bindings.workspace.session_dir),
|
||||
pr_number=99,
|
||||
)
|
||||
return bindings, loop, thread
|
||||
|
||||
|
||||
def test_fetch_pr_returns_premise_and_changed_files(db: Database, tmp_path: Path) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/repos/octo/widget/pulls/99":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 99,
|
||||
"html_url": "https://github.com/octo/widget/pull/99",
|
||||
"title": "Fix crash",
|
||||
"body": "Fixes #42",
|
||||
"head": {"ref": "fix-crash", "repo": {"full_name": "alice/widget"}},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
},
|
||||
)
|
||||
if request.url.path == "/repos/octo/widget/pulls/99/files":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"filename": "src/app.py", "status": "modified", "additions": 5, "deletions": 2}],
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "fetch_pr")
|
||||
result = tool.execute({}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert "Fix crash" in result
|
||||
assert "#42" in result
|
||||
assert "`src/app.py` (modified, +5/-2)" in result
|
||||
|
||||
|
||||
def test_classify_pr_applies_review_labels_and_persists_rank(db: Database, tmp_path: Path) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["path"] = request.url.path
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json=[{"name": label} for label in captured["body"]["labels"]])
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "classify_pr")
|
||||
result = tool.execute(
|
||||
{
|
||||
"rank": "review:p1",
|
||||
"type": "fix",
|
||||
"area": ["tool", "unknown"],
|
||||
"provider": "provider:openai",
|
||||
"rationale": "fixes the tool crash with a scoped guard",
|
||||
},
|
||||
_ctx(),
|
||||
)
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert "review:p1" in result
|
||||
assert captured["path"].endswith("/issues/99/labels")
|
||||
assert captured["body"]["labels"] == ["triaged", "review:p1", "fix", "tool", "providers", "provider:openai"]
|
||||
row = db.get_issue(bindings.issue_key)
|
||||
assert row is not None and row.classification == "review:p1"
|
||||
|
||||
|
||||
def test_classify_pr_rejects_bad_rank(db: Database, tmp_path: Path) -> None:
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "classify_pr")
|
||||
with pytest.raises(RpcCommandError):
|
||||
tool.execute({"rank": "prio:p1", "type": "fix", "rationale": "wrong namespace"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
|
||||
def test_pr_review_comment_stages_and_submit_flushes_one_comment_review(db: Database, tmp_path: Path) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/reviews"):
|
||||
captured["path"] = request.url.path
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 44,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": captured["body"]["body"],
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment")
|
||||
submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review")
|
||||
staged = stage_tool.execute(
|
||||
{
|
||||
"path": "src/app.py",
|
||||
"line": 12,
|
||||
"side": "RIGHT",
|
||||
"start_line": 10,
|
||||
"start_side": "RIGHT",
|
||||
"body": "blocking: this dereferences cfg before the guard.",
|
||||
},
|
||||
_ctx(),
|
||||
)
|
||||
assert "staged_count=1" in staged
|
||||
rows = db.list_staged_review_comments(bindings.issue_key)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].path == "src/app.py"
|
||||
|
||||
result = submit_tool.execute({"body": "review:p1 — one blocking issue", "event": "APPROVE"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert "submitted PR review" in result
|
||||
assert captured["path"].endswith("/pulls/99/reviews")
|
||||
assert captured["body"] == {
|
||||
"body": "review:p1 — one blocking issue",
|
||||
"event": "COMMENT",
|
||||
"comments": [
|
||||
{
|
||||
"path": "src/app.py",
|
||||
"line": 12,
|
||||
"side": "RIGHT",
|
||||
"body": "blocking: this dereferences cfg before the guard.",
|
||||
"start_line": 10,
|
||||
"start_side": "RIGHT",
|
||||
}
|
||||
],
|
||||
}
|
||||
assert db.list_staged_review_comments(bindings.issue_key) == []
|
||||
|
||||
|
||||
def test_submit_pr_review_posts_summary_only_when_no_staged_comments(db: Database, tmp_path: Path) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": 45, "user": {"login": "robomp-bot"}, "body": "ok", "state": "COMMENTED", "submitted_at": "t"},
|
||||
)
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "submit_pr_review")
|
||||
result = tool.execute({"body": "lgtm — scoped fix"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert "comments=0" in result
|
||||
assert captured["body"]["event"] == "COMMENT"
|
||||
assert captured["body"]["comments"] == []
|
||||
|
||||
|
||||
def test_submit_pr_review_failure_keeps_staged_comments(db: Database, tmp_path: Path) -> None:
|
||||
bindings, loop, t = _review_bindings(
|
||||
db,
|
||||
tmp_path,
|
||||
httpx.MockTransport(lambda _request: httpx.Response(422, json={"message": "Validation failed"})),
|
||||
)
|
||||
try:
|
||||
stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment")
|
||||
submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review")
|
||||
stage_tool.execute({"path": "src/app.py", "line": 12, "body": "finding"}, _ctx())
|
||||
with pytest.raises(RpcCommandError):
|
||||
submit_tool.execute({"body": "summary"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
rows = db.list_staged_review_comments(bindings.issue_key)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].path == "src/app.py"
|
||||
|
||||
|
||||
def test_review_tools_reject_outside_review_mode(db: Database, tmp_path: Path) -> None:
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "pr_review_comment")
|
||||
with pytest.raises(RpcCommandError):
|
||||
tool.execute({"path": "x.py", "line": 1, "body": "nit"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
|
||||
def test_review_mode_rejects_push_and_open_pr_before_repo_commands(db: Database, tmp_path: Path) -> None:
|
||||
calls: list[list[str] | tuple[str, ...]] = []
|
||||
|
||||
def record_repo_command(_bindings: ToolBindings, cmd: list[str] | tuple[str, ...], *, timeout: float | None = None):
|
||||
del timeout
|
||||
calls.append(cmd)
|
||||
raise AssertionError("repo command must not run in review mode")
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
|
||||
try:
|
||||
original = host_tools._run_repo_command
|
||||
host_tools._run_repo_command = record_repo_command # type: ignore[assignment]
|
||||
push = next(x for x in build(bindings) if x.name == "gh_push_branch")
|
||||
open_pr = next(x for x in build(bindings) if x.name == "gh_open_pr")
|
||||
with pytest.raises(RpcCommandError):
|
||||
push.execute({}, _ctx())
|
||||
with pytest.raises(RpcCommandError):
|
||||
open_pr.execute({"title": "t", "body": "invalid"}, _ctx())
|
||||
finally:
|
||||
host_tools._run_repo_command = original # type: ignore[assignment]
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_classify_issue_on_pr_thread_is_noop(db: Database, tmp_path: Path) -> None:
|
||||
"""On PR threads the tool must not hit GitHub and must not raise."""
|
||||
calls: list[str] = []
|
||||
|
||||
@@ -36,6 +36,16 @@ class _Workspace:
|
||||
repo_dir: str = "/tmp/repo"
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class _Pr:
|
||||
number: int = 99
|
||||
author: str = "alice"
|
||||
head_ref: str = "fix-crash"
|
||||
base_ref: str = "main"
|
||||
head_repo: str = "alice/widget"
|
||||
html_url: str = "https://github.com/octo/widget/pull/99"
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class _Comment:
|
||||
id: int = 1
|
||||
@@ -151,3 +161,23 @@ def test_resume_triage_renders_branch_and_issue() -> None:
|
||||
assert "broken thing" in out
|
||||
# The prompt instructs the agent to reconcile drift via fetch_issue_thread.
|
||||
assert "fetch_issue_thread" in out
|
||||
|
||||
|
||||
def test_kickoff_pr_review_formats_head_repo_and_origin_base() -> None:
|
||||
out = persona.kickoff_pr_review(
|
||||
repo=_Repo(),
|
||||
pr=_Pr(),
|
||||
workspace=_Workspace(),
|
||||
)
|
||||
assert "`fix-crash` from `alice/widget`" in out
|
||||
assert "git diff origin/main...HEAD" in out
|
||||
|
||||
|
||||
def test_review_completion_reminder_mentions_submit_only() -> None:
|
||||
out = persona.review_completion_reminder(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(number=99, title="Fix parser"),
|
||||
workspace=_Workspace(branch="review/pr-99"),
|
||||
)
|
||||
assert "submit_pr_review" in out
|
||||
assert "gh_open_pr" not in out
|
||||
|
||||
@@ -22,6 +22,7 @@ from robomp.github_client import (
|
||||
GitHubError,
|
||||
IssueInfo,
|
||||
IssueSummary,
|
||||
PullRequestFileInfo,
|
||||
PullRequestInfo,
|
||||
PullRequestReviewInfo,
|
||||
ReactionInfo,
|
||||
@@ -276,7 +277,7 @@ def round_trip_app(proxy_settings: Settings):
|
||||
}
|
||||
],
|
||||
)
|
||||
if path == "/repos/octo/widget/pulls/2/reviews":
|
||||
if path == "/repos/octo/widget/pulls/2/reviews" and req.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
@@ -289,6 +290,25 @@ def round_trip_app(proxy_settings: Settings):
|
||||
}
|
||||
],
|
||||
)
|
||||
if path == "/repos/octo/widget/pulls/2/files":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"filename": "src/app.py", "status": "modified", "additions": 2, "deletions": 1}],
|
||||
)
|
||||
if path == "/repos/octo/widget/pulls/2/reviews" and req.method == "POST":
|
||||
body = json.loads(req.content)
|
||||
assert body["event"] == "COMMENT"
|
||||
assert body["comments"] == [{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}]
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 55,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": body["body"],
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
if path == "/user":
|
||||
return httpx.Response(200, json={"login": "robomp-bot"})
|
||||
if path == "/repos/octo/widget/pulls/4" and req.method == "GET":
|
||||
@@ -353,6 +373,19 @@ async def test_round_trip_all_endpoints(round_trip_app) -> None:
|
||||
prs = await client.list_pr_reviews("octo/widget", 2)
|
||||
assert len(prs) == 1 and isinstance(prs[0], PullRequestReviewInfo)
|
||||
|
||||
files = await client.list_pr_files("octo/widget", 2)
|
||||
assert len(files) == 1 and isinstance(files[0], PullRequestFileInfo)
|
||||
assert files[0].path == "src/app.py"
|
||||
|
||||
submitted = await client.submit_pr_review(
|
||||
repo="octo/widget",
|
||||
pr_number=2,
|
||||
body="summary",
|
||||
event="COMMENT",
|
||||
comments=[{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}],
|
||||
)
|
||||
assert submitted.id == 55
|
||||
|
||||
assert await client.get_authenticated_login() == "robomp-bot"
|
||||
|
||||
existing_pr = await client.get_pull_request("octo/widget", 4)
|
||||
|
||||
@@ -393,6 +393,73 @@ def test_ensure_workspace_creates_worktree(tmp_path: Path, upstream_repo: Path)
|
||||
assert ws.artifacts_dir.is_dir()
|
||||
|
||||
|
||||
def test_ensure_workspace_pr_head_uses_detached_pr_ref(tmp_path: Path, upstream_repo: Path) -> None:
|
||||
contributor = tmp_path / "contributor"
|
||||
_git(["clone", str(upstream_repo), str(contributor)], cwd=tmp_path)
|
||||
(contributor / "README.md").write_text("hello from pr\n", encoding="utf-8")
|
||||
_git(["-C", str(contributor), "add", "README.md"], cwd=tmp_path)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "pr change"],
|
||||
cwd=str(contributor),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ
|
||||
| {
|
||||
"GIT_AUTHOR_NAME": "c",
|
||||
"GIT_AUTHOR_EMAIL": "c@t",
|
||||
"GIT_COMMITTER_NAME": "c",
|
||||
"GIT_COMMITTER_EMAIL": "c@t",
|
||||
},
|
||||
)
|
||||
pr_head = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=str(contributor),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
_git(["-C", str(contributor), "push", "origin", "HEAD:refs/pull/9/head"], cwd=tmp_path)
|
||||
|
||||
mgr = SandboxManager(tmp_path / "workspaces")
|
||||
ws = mgr.ensure_workspace(
|
||||
repo="octo/widget",
|
||||
number=9,
|
||||
title="incoming PR",
|
||||
clone_url=str(upstream_repo),
|
||||
default_branch="main",
|
||||
pr_head=9,
|
||||
author_name="robomp-bot",
|
||||
author_email="robomp-bot@example.invalid",
|
||||
)
|
||||
|
||||
head = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=str(ws.repo_dir),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
symbolic = subprocess.run(
|
||||
["git", "symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
cwd=str(ws.repo_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
pushurl = subprocess.run(
|
||||
["git", "config", "--get", "remote.origin.pushurl"],
|
||||
cwd=str(ws.repo_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert head == pr_head
|
||||
assert symbolic.returncode != 0
|
||||
assert ws.branch == "review/pr-9"
|
||||
assert pushurl.returncode != 0
|
||||
|
||||
|
||||
def test_chown_workspace_noops_when_not_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[list[str], bool]] = []
|
||||
|
||||
|
||||
@@ -888,20 +888,20 @@ def test_webhook_rate_limits_unknown_submitter_at_default_cap(rate_limited_setti
|
||||
assert states == ["queued", "queued", "skipped"]
|
||||
|
||||
|
||||
def test_webhook_unmapped_pr_comment_queues_with_pr_key_and_counts_budget(
|
||||
def test_webhook_incoming_pr_comment_without_directive_skips_without_counting_budget(
|
||||
rate_limited_settings: Settings,
|
||||
) -> None:
|
||||
app = create_app(rate_limited_settings)
|
||||
with TestClient(app) as client:
|
||||
queued = _post_pr_issue_comment(
|
||||
skipped = _post_pr_issue_comment(
|
||||
client,
|
||||
delivery="pr-unmapped",
|
||||
user="stranger",
|
||||
pr_number=900,
|
||||
association="NONE",
|
||||
)
|
||||
assert queued.status_code == 202
|
||||
assert queued.json()["state"] == "queued"
|
||||
assert skipped.status_code == 202
|
||||
assert skipped.json()["state"] == "skipped"
|
||||
|
||||
states = []
|
||||
for i in range(3):
|
||||
@@ -921,8 +921,8 @@ def test_webhook_unmapped_pr_comment_queues_with_pr_key_and_counts_budget(
|
||||
|
||||
assert unmapped is not None
|
||||
assert unmapped.issue_key == "octo/widget#900"
|
||||
assert unmapped.last_error is None
|
||||
assert states == ["queued", "skipped", "skipped"]
|
||||
assert "incoming PR comments ignored" in (unmapped.last_error or "")
|
||||
assert states == ["queued", "queued", "skipped"]
|
||||
|
||||
|
||||
def test_webhook_contributor_gets_higher_cap(rate_limited_settings: Settings) -> None:
|
||||
@@ -1554,6 +1554,7 @@ class _RecordingSandbox:
|
||||
clone_url: str,
|
||||
default_branch: str,
|
||||
existing_branch=None,
|
||||
pr_head: int | None = None,
|
||||
author_name: str = "",
|
||||
author_email: str = "",
|
||||
slot_uid: int | None = None,
|
||||
@@ -1565,6 +1566,7 @@ class _RecordingSandbox:
|
||||
"title": title,
|
||||
"default_branch": default_branch,
|
||||
"existing_branch": existing_branch,
|
||||
"pr_head": pr_head,
|
||||
"slot_uid": slot_uid,
|
||||
}
|
||||
)
|
||||
@@ -1581,7 +1583,7 @@ class _RecordingSandbox:
|
||||
|
||||
wid = f"{repo.replace('/', '__')}__{number}"
|
||||
return _W(
|
||||
branch=existing_branch or f"farm/auto/{wid}",
|
||||
branch=existing_branch or (f"review/pr-{pr_head}" if pr_head is not None else f"farm/auto/{wid}"),
|
||||
session_dir=self.tmp_root / wid / "session",
|
||||
context_dir=self.tmp_root / wid / "context",
|
||||
repo_dir=self.tmp_root / wid / "repo",
|
||||
@@ -1823,6 +1825,198 @@ async def test_handle_pr_conversation_repairs_missing_pr_mapping_from_branch(
|
||||
close_database()
|
||||
|
||||
|
||||
async def test_handle_pr_conversation_skips_review_workspace_rows(
|
||||
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
|
||||
) -> None:
|
||||
from robomp import tasks
|
||||
from robomp.github_client import GitHubClient, PullRequestInfo
|
||||
|
||||
sandbox = _RecordingSandbox(tmp_path)
|
||||
db = get_database(settings.sqlite_path)
|
||||
db.upsert_issue(
|
||||
key="octo/widget#900",
|
||||
repo="octo/widget",
|
||||
number=900,
|
||||
state="reviewing",
|
||||
branch="review/pr-900",
|
||||
pr_number=900,
|
||||
)
|
||||
|
||||
async def _get_pull_request(self, repo_full: str, number: int):
|
||||
assert repo_full == "octo/widget"
|
||||
assert number == 900
|
||||
return PullRequestInfo(
|
||||
repo="octo/widget",
|
||||
number=900,
|
||||
html_url="https://github.com/octo/widget/pull/900",
|
||||
head_ref="contrib/fix",
|
||||
base_ref="main",
|
||||
state="open",
|
||||
author="alice",
|
||||
head_repo="alice/widget",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request)
|
||||
|
||||
payload = {
|
||||
"action": "created",
|
||||
"issue": {
|
||||
"number": 900,
|
||||
"user": {"login": "alice"},
|
||||
"pull_request": {"url": "https://api.github.com/repos/octo/widget/pulls/900"},
|
||||
},
|
||||
"comment": {"user": {"login": "can1357"}, "body": "@robomp-bot please re-review", "id": 12},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
"_robomp_directive": {"body": "please re-review", "author": "can1357"},
|
||||
}
|
||||
await tasks.handle_pr_conversation(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=GitHubClient("t"),
|
||||
git_transport=LocalGitTransport(token=None),
|
||||
sandbox=sandbox,
|
||||
payload=payload,
|
||||
delivery_id="test-pr-review-row",
|
||||
)
|
||||
|
||||
assert stub_run_task == []
|
||||
assert sandbox.ensure_calls == []
|
||||
close_database()
|
||||
|
||||
|
||||
async def test_review_pr_retries_when_ranked_but_not_submitted(
|
||||
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
|
||||
) -> None:
|
||||
from robomp import tasks
|
||||
from robomp.github_client import GitHubClient, IssueInfo, PullRequestInfo, RepoInfo
|
||||
|
||||
sandbox = _RecordingSandbox(tmp_path)
|
||||
db = get_database(settings.sqlite_path)
|
||||
repo = RepoInfo(
|
||||
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
|
||||
)
|
||||
issue = IssueInfo(
|
||||
repo="octo/widget",
|
||||
number=900,
|
||||
title="Fix parser",
|
||||
body="body",
|
||||
state="open",
|
||||
author="alice",
|
||||
labels=("triaged", "review:p1"),
|
||||
is_pull_request=True,
|
||||
)
|
||||
pr = PullRequestInfo(
|
||||
repo="octo/widget",
|
||||
number=900,
|
||||
html_url="https://github.com/octo/widget/pull/900",
|
||||
head_ref="alice/fix-parser",
|
||||
base_ref="main",
|
||||
state="open",
|
||||
author="alice",
|
||||
head_repo="alice/widget",
|
||||
)
|
||||
|
||||
async def _get_repo(self, repo_full: str):
|
||||
assert repo_full == "octo/widget"
|
||||
return repo
|
||||
|
||||
async def _get_issue(self, repo_full: str, number: int):
|
||||
assert repo_full == "octo/widget"
|
||||
assert number == 900
|
||||
return issue
|
||||
|
||||
async def _get_pull_request(self, repo_full: str, number: int):
|
||||
assert repo_full == "octo/widget"
|
||||
assert number == 900
|
||||
return pr
|
||||
|
||||
monkeypatch.setattr(GitHubClient, "get_repo", _get_repo)
|
||||
monkeypatch.setattr(GitHubClient, "get_issue", _get_issue)
|
||||
monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request)
|
||||
|
||||
await tasks.review_pr(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=GitHubClient("t"),
|
||||
sandbox=sandbox,
|
||||
git_transport=LocalGitTransport(token=None),
|
||||
payload={"pull_request": {"number": 900}, "repository": {"full_name": "octo/widget"}},
|
||||
delivery_id="d-review-retry",
|
||||
)
|
||||
|
||||
assert len(stub_run_task) == 1
|
||||
assert stub_run_task[0]["task_kind"] == "review_pr"
|
||||
assert sandbox.ensure_calls[0]["pr_head"] == 900
|
||||
close_database()
|
||||
|
||||
|
||||
async def test_review_pr_skips_after_submitted_review(
|
||||
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
|
||||
) -> None:
|
||||
from robomp import tasks
|
||||
from robomp.github_client import GitHubClient, IssueInfo, PullRequestInfo, RepoInfo
|
||||
|
||||
sandbox = _RecordingSandbox(tmp_path)
|
||||
db = get_database(settings.sqlite_path)
|
||||
key = issue_key("octo/widget", 900)
|
||||
db.log_tool_call(issue_key=key, tool="submit_pr_review", args={"body": "done"}, result={"review_id": 12})
|
||||
repo = RepoInfo(
|
||||
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
|
||||
)
|
||||
issue = IssueInfo(
|
||||
repo="octo/widget",
|
||||
number=900,
|
||||
title="Fix parser",
|
||||
body="body",
|
||||
state="open",
|
||||
author="alice",
|
||||
labels=("triaged", "review:p1"),
|
||||
is_pull_request=True,
|
||||
)
|
||||
pr = PullRequestInfo(
|
||||
repo="octo/widget",
|
||||
number=900,
|
||||
html_url="https://github.com/octo/widget/pull/900",
|
||||
head_ref="alice/fix-parser",
|
||||
base_ref="main",
|
||||
state="open",
|
||||
author="alice",
|
||||
head_repo="alice/widget",
|
||||
)
|
||||
|
||||
async def _get_repo(self, repo_full: str):
|
||||
assert repo_full == "octo/widget"
|
||||
return repo
|
||||
|
||||
async def _get_issue(self, repo_full: str, number: int):
|
||||
assert repo_full == "octo/widget"
|
||||
assert number == 900
|
||||
return issue
|
||||
|
||||
async def _get_pull_request(self, repo_full: str, number: int):
|
||||
assert repo_full == "octo/widget"
|
||||
assert number == 900
|
||||
return pr
|
||||
|
||||
monkeypatch.setattr(GitHubClient, "get_repo", _get_repo)
|
||||
monkeypatch.setattr(GitHubClient, "get_issue", _get_issue)
|
||||
monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request)
|
||||
|
||||
await tasks.review_pr(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=GitHubClient("t"),
|
||||
sandbox=sandbox,
|
||||
git_transport=LocalGitTransport(token=None),
|
||||
payload={"pull_request": {"number": 900}, "repository": {"full_name": "octo/widget"}},
|
||||
delivery_id="d-review-skip",
|
||||
)
|
||||
|
||||
assert stub_run_task == []
|
||||
assert sandbox.ensure_calls == []
|
||||
close_database()
|
||||
|
||||
|
||||
async def test_handle_comment_directive_bootstraps_untriaged_issue(
|
||||
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
|
||||
) -> None:
|
||||
|
||||
@@ -659,10 +659,74 @@ async def test_run_rpc_skips_reminder_when_unclassified(tmp_path: Path, settings
|
||||
assert len(fake.prompts) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_review_pr_reminds_until_submit_pr_review(tmp_path: Path, settings: Settings) -> None:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="review_pr",
|
||||
prompt="kickoff",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
fake = _FakeRpcClient.instances[0]
|
||||
assert len(fake.prompts) == 1 + settings.task_completion_max_reminders
|
||||
assert fake.prompts[0] == "kickoff"
|
||||
assert all("submit_pr_review" in p for p in fake.prompts[1:])
|
||||
assert all("gh_open_pr" not in p for p in fake.prompts[1:])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_review_pr_stops_after_submit_without_dirty_probe(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
|
||||
def _probe(_workspace, _slot_uid): # type: ignore[no-untyped-def]
|
||||
raise AssertionError("review_pr must not run dirty-state probes")
|
||||
|
||||
monkeypatch.setattr(worker, "_probe_workspace_dirty", _probe)
|
||||
original_on_tool_end = _FakeRpcClient.on_tool_execution_end
|
||||
|
||||
def _record_tool_end(self, cb) -> None:
|
||||
self._tool_end_callbacks = getattr(self, "_tool_end_callbacks", [])
|
||||
self._tool_end_callbacks.append(cb)
|
||||
|
||||
def _on_prompt(client: _FakeRpcClient, _prompt: str) -> None:
|
||||
for cb in client._tool_end_callbacks:
|
||||
cb(SimpleNamespace(tool_name="submit_pr_review", result={}))
|
||||
|
||||
_FakeRpcClient.on_tool_execution_end = _record_tool_end # type: ignore[assignment]
|
||||
try:
|
||||
_FakeRpcClient.on_prompt = staticmethod(_on_prompt) # type: ignore[attr-defined]
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="review_pr",
|
||||
prompt="kickoff",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
finally:
|
||||
_FakeRpcClient.on_tool_execution_end = original_on_tool_end # type: ignore[assignment]
|
||||
delattr(_FakeRpcClient, "on_prompt")
|
||||
|
||||
fake = _FakeRpcClient.instances[0]
|
||||
assert fake.prompts == ["kickoff"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dirty-state watchdog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_sends_dirty_state_reminder_when_worktree_has_unpushed_work(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
Reference in New Issue
Block a user