feat(robomp): resume needs-info issues (#2728)
This commit is contained in:
@@ -382,10 +382,94 @@ def test_repro_record_writes_transcript(db: Database, tmp_path: Path) -> None:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
|
||||
def test_repro_record_clears_needs_info_after_actionable_reply(db: Database, tmp_path: Path) -> None:
|
||||
removed: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
removed.append((request.method, request.url.path))
|
||||
return httpx.Response(204)
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
db.set_issue_state(bindings.issue_key, "needs_info")
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "repro_record")
|
||||
result = tool.execute(
|
||||
{
|
||||
"title": "panic on empty input",
|
||||
"command": "bun test foo.test.ts",
|
||||
"output": "Error: boom",
|
||||
"exit_code": 1,
|
||||
},
|
||||
_ctx(),
|
||||
)
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert result == "recorded"
|
||||
assert removed == [("DELETE", "/repos/octo/widget/issues/42/labels/needs-info")]
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "reproducing"
|
||||
|
||||
|
||||
def test_repro_record_advances_needs_info_when_label_is_missing(db: Database, tmp_path: Path) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "DELETE"
|
||||
assert request.url.path == "/repos/octo/widget/issues/42/labels/needs-info"
|
||||
return httpx.Response(404, json={"message": "Label does not exist"})
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
db.set_issue_state(bindings.issue_key, "needs_info")
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "repro_record")
|
||||
tool.execute(
|
||||
{
|
||||
"title": "panic on empty input",
|
||||
"command": "bun test foo.test.ts",
|
||||
"output": "Error: boom",
|
||||
"exit_code": 1,
|
||||
},
|
||||
_ctx(),
|
||||
)
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "reproducing"
|
||||
|
||||
|
||||
def test_repro_record_advances_needs_info_when_cleanup_transport_fails(db: Database, tmp_path: Path) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("connection dropped", request=request)
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
db.set_issue_state(bindings.issue_key, "needs_info")
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "repro_record")
|
||||
result = tool.execute(
|
||||
{
|
||||
"title": "panic on empty input",
|
||||
"command": "bun test foo.test.ts",
|
||||
"output": "Error: boom",
|
||||
"exit_code": 1,
|
||||
},
|
||||
_ctx(),
|
||||
)
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert result == "recorded"
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "reproducing"
|
||||
|
||||
|
||||
def test_repro_record_chowns_to_slot_when_root(db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
chowns: list[tuple[Path, int, int]] = []
|
||||
monkeypatch.setattr(host_tools, "_slot_permissions_active", lambda slot_uid: slot_uid is not None)
|
||||
monkeypatch.setattr("robomp.host_tools.os.chown", lambda path, uid, gid: chowns.append((Path(path), uid, gid)))
|
||||
monkeypatch.setattr(
|
||||
"robomp.host_tools.os.chown",
|
||||
lambda path, uid, gid: chowns.append((Path(path), uid, gid)),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda r: httpx.Response(500)), slot_uid=2001)
|
||||
try:
|
||||
@@ -419,12 +503,15 @@ def test_repro_record_rejects_bad_args(db: Database, tmp_path: Path) -> None:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
|
||||
def test_mark_unable_posts_comment_and_abandons(db: Database, tmp_path: Path) -> None:
|
||||
def test_mark_unable_posts_comment_marks_needs_info_and_labels_issue(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(201, json={"id": 77, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
|
||||
if request.url.path.endswith("/labels"):
|
||||
captured["labels"] = json.loads(request.content)
|
||||
return httpx.Response(200, json=[{"name": "bug"}, {"name": "needs-info"}])
|
||||
captured["comment"] = json.loads(request.content)
|
||||
return httpx.Response(201, json={"id": 321, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
@@ -432,10 +519,57 @@ def test_mark_unable_posts_comment_and_abandons(db: Database, tmp_path: Path) ->
|
||||
result = tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
assert "abandonment" in result
|
||||
assert "Could not reproduce" in captured["body"]["body"]
|
||||
|
||||
assert "needs-info comment" in result
|
||||
assert captured["labels"] == {"labels": ["needs-info"]}
|
||||
assert "resume from this context" in captured["comment"]["body"]
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "abandoned"
|
||||
assert issue and issue.state == "needs_info"
|
||||
|
||||
|
||||
def test_mark_unable_keeps_needs_info_when_label_is_missing(db: Database, tmp_path: Path) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/labels"):
|
||||
return httpx.Response(422, json={"message": "Label does not exist"})
|
||||
return httpx.Response(201, json={"id": 321, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "mark_unable_to_reproduce")
|
||||
tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "needs_info"
|
||||
|
||||
|
||||
def test_mark_unable_keeps_needs_info_when_label_transport_fails(db: Database, tmp_path: Path) -> None:
|
||||
comments = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal comments
|
||||
if request.url.path.endswith("/labels"):
|
||||
raise httpx.ConnectError("connection dropped", request=request)
|
||||
comments += 1
|
||||
return httpx.Response(201, json={"id": 321, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "mark_unable_to_reproduce")
|
||||
tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert comments == 1
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "needs_info"
|
||||
row = db._conn.execute(
|
||||
"SELECT result_json FROM tool_calls WHERE tool='mark_unable_to_reproduce' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
result = json.loads(row["result_json"])
|
||||
assert "ConnectError" in result["label_error"]
|
||||
|
||||
|
||||
def test_abort_task_signals_controller_and_abandons_without_comment(db: Database, tmp_path: Path) -> None:
|
||||
|
||||
@@ -183,6 +183,16 @@ def test_review_completion_reminder_mentions_submit_only() -> None:
|
||||
assert "gh_open_pr" not in out
|
||||
|
||||
|
||||
def test_completion_reminder_limits_mark_unable_to_reporter_details() -> None:
|
||||
out = persona.completion_reminder(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
workspace=_Workspace(),
|
||||
)
|
||||
assert "reporter-provided reproduction details" in out
|
||||
assert "maintainer input" not in out
|
||||
|
||||
|
||||
def test_system_append_renders_configured_bot_login() -> None:
|
||||
out = persona.system_append(
|
||||
repo=_Repo(),
|
||||
@@ -196,6 +206,17 @@ def test_system_append_renders_configured_bot_login() -> None:
|
||||
assert "**robomp**" not in out
|
||||
|
||||
|
||||
def test_system_append_routes_push_refusal_to_maintainer_comment_only() -> None:
|
||||
out = persona.system_append(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
workspace=_Workspace(),
|
||||
bot_login="Svitter",
|
||||
)
|
||||
assert "Push refused for reasons you cannot resolve? Ask the maintainer via `gh_post_comment`." in out
|
||||
assert "or use `mark_unable_to_reproduce`" not in out
|
||||
|
||||
|
||||
def test_system_append_pr_review_renders_configured_bot_login() -> None:
|
||||
out = persona.system_append_pr_review(
|
||||
repo=_Repo(),
|
||||
|
||||
@@ -338,6 +338,8 @@ def round_trip_app(proxy_settings: Settings):
|
||||
return httpx.Response(201, json={})
|
||||
if path == "/repos/octo/widget/issues/1/labels":
|
||||
return httpx.Response(200, json=[{"name": "triage"}])
|
||||
if path == "/repos/octo/widget/issues/1/labels/needs-info" and req.method == "DELETE":
|
||||
return httpx.Response(200, json={})
|
||||
if path == "/repos/octo/widget/issues/1/assignees":
|
||||
return httpx.Response(201, json={})
|
||||
return httpx.Response(404, json={"message": f"unrouted {req.method} {path}"})
|
||||
@@ -405,6 +407,7 @@ async def test_round_trip_all_endpoints(round_trip_app) -> None:
|
||||
assert await client.request_reviewers(repo="octo/widget", pr_number=4, reviewers=["alice"]) is None
|
||||
|
||||
labels = await client.add_issue_labels("octo/widget", 1, ["triage"])
|
||||
assert await client.remove_issue_label("octo/widget", 1, "needs-info") is None
|
||||
assert labels == ("triage",)
|
||||
|
||||
assert await client.add_assignees("octo/widget", 1, ["alice"]) is None
|
||||
|
||||
@@ -505,6 +505,27 @@ async def test_add_issue_labels(proxy_settings: Settings) -> None:
|
||||
assert json.loads(captured["req"].content) == {"labels": ["triage", "bug"]}
|
||||
|
||||
|
||||
async def test_remove_issue_label(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","number":1,"label":"needs-info"}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/remove_issue_label",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/remove_issue_label", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
assert captured["req"].method == "DELETE"
|
||||
assert captured["req"].url.path == "/repos/octo/widget/issues/1/labels/needs-info"
|
||||
|
||||
|
||||
async def test_add_assignees(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
|
||||
@@ -2211,6 +2211,87 @@ async def test_handle_comment_finalized_without_directive_still_replies(
|
||||
close_database()
|
||||
|
||||
|
||||
async def test_handle_comment_resumes_needs_info_without_preemptive_cleanup(
|
||||
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
|
||||
) -> None:
|
||||
"""A needs-info reply resumes first; host tools clear state only after actionable work."""
|
||||
from robomp import tasks
|
||||
from robomp.github_client import GitHubClient, IssueInfo, RepoInfo
|
||||
|
||||
sandbox = _RecordingSandbox(tmp_path)
|
||||
db = get_database(settings.sqlite_path)
|
||||
db.upsert_issue(
|
||||
key="octo/widget#88",
|
||||
repo="octo/widget",
|
||||
number=88,
|
||||
state="needs_info",
|
||||
branch="farm/old/branch",
|
||||
)
|
||||
|
||||
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=88,
|
||||
title="boom",
|
||||
body="details",
|
||||
state="open",
|
||||
author="alice",
|
||||
labels=("needs-info",),
|
||||
is_pull_request=False,
|
||||
)
|
||||
|
||||
async def _resolve(_gh, _payload):
|
||||
return repo, issue
|
||||
|
||||
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
|
||||
|
||||
post_comment_calls: list = []
|
||||
removed_labels: list[tuple[str, int, str]] = []
|
||||
|
||||
async def _capture_post(self, *args, **kwargs):
|
||||
post_comment_calls.append((args, kwargs))
|
||||
return None
|
||||
|
||||
async def _capture_remove_label(self, repo: str, number: int, label: str) -> None:
|
||||
removed_labels.append((repo, number, label))
|
||||
|
||||
monkeypatch.setattr(GitHubClient, "post_comment", _capture_post)
|
||||
monkeypatch.setattr(GitHubClient, "remove_issue_label", _capture_remove_label)
|
||||
|
||||
payload = {
|
||||
"action": "created",
|
||||
"issue": {"number": 88, "user": {"login": "alice"}, "title": "boom"},
|
||||
"comment": {
|
||||
"user": {"login": "alice"},
|
||||
"body": "I am on Bun 1.3.14 and here is the trace",
|
||||
"id": 4,
|
||||
"created_at": "2026-05-14T23:00:00Z",
|
||||
},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
}
|
||||
await tasks.handle_comment(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=GitHubClient("t"),
|
||||
git_transport=LocalGitTransport(token=None),
|
||||
sandbox=sandbox,
|
||||
payload=payload,
|
||||
delivery_id="test-delivery-needs-info",
|
||||
)
|
||||
assert len(stub_run_task) == 1
|
||||
call = stub_run_task[0]
|
||||
assert call["task_kind"] == "handle_comment"
|
||||
assert call["comment"].body == "I am on Bun 1.3.14 and here is the trace"
|
||||
assert sandbox.ensure_calls[0]["existing_branch"] == "farm/old/branch"
|
||||
assert removed_labels == []
|
||||
assert post_comment_calls == [], "needs-info replies must not get the finalized-issue notice"
|
||||
row = db.get_issue("octo/widget#88")
|
||||
assert row is not None and row.state == "needs_info"
|
||||
close_database()
|
||||
|
||||
|
||||
async def test_directive_handler_attaches_thread_from_github(
|
||||
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user