Files
2026-07-13 12:22:59 +08:00

3607 lines
128 KiB
Python

"""Host tool tests against a mocked GitHub via httpx.MockTransport."""
from __future__ import annotations
import asyncio
import json
import threading
from pathlib import Path
from typing import Any
import httpx
import pytest
from omp_rpc import HostToolContext, RpcCommandError
from robomp import host_tools
from robomp.db import Database
from robomp.github_client import GitHubClient, IssueInfo, RepoInfo
from robomp.host_tools import AbortController, ToolBindings, build
from robomp.sandbox import LocalGitTransport, Workspace
def _stub_workspace(tmp_path: Path) -> Workspace:
root = tmp_path / "ws"
repo_dir = root / "repo"
session_dir = root / ".omp-session"
context_dir = root / "context"
artifacts_dir = root / "artifacts"
for p in (root, repo_dir, session_dir, context_dir, context_dir / "repro", artifacts_dir):
p.mkdir(parents=True, exist_ok=True)
return Workspace(
root=root,
repo_dir=repo_dir,
session_dir=session_dir,
context_dir=context_dir,
artifacts_dir=artifacts_dir,
branch="farm/abc12345/some-issue",
repo_full_name="octo/widget",
issue_number=42,
)
def _stub_issue() -> IssueInfo:
return IssueInfo(
repo="octo/widget",
number=42,
title="boom",
body="b",
state="open",
author="alice",
labels=("bug",),
is_pull_request=False,
)
def _stub_repo() -> RepoInfo:
return RepoInfo(
full_name="octo/widget",
default_branch="main",
clone_url="https://x/octo/widget.git",
private=False,
)
def _make_loop_in_background() -> tuple[asyncio.AbstractEventLoop, threading.Thread]:
loop = asyncio.new_event_loop()
t = threading.Thread(target=loop.run_forever, daemon=True)
t.start()
return loop, t
def _stop_loop(loop: asyncio.AbstractEventLoop, t: threading.Thread) -> None:
loop.call_soon_threadsafe(loop.stop)
t.join(timeout=2.0)
loop.close()
def _bindings(
db: Database, tmp_path: Path, transport: httpx.MockTransport, *, slot_uid: int | None = None
) -> tuple[ToolBindings, asyncio.AbstractEventLoop, threading.Thread]:
github = GitHubClient("token", transport=transport)
loop, thread = _make_loop_in_background()
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=_stub_issue(),
workspace=_stub_workspace(tmp_path),
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
slot_uid=slot_uid,
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=bindings.workspace.branch,
session_dir=str(bindings.workspace.session_dir),
)
return bindings, loop, thread
def _ctx() -> HostToolContext[Any]:
return HostToolContext(tool_call_id="tc-1", _cancel_event=threading.Event(), _send_update=lambda _payload: None)
def test_repo_command_env_scrubs_secrets_and_uses_workspace_cache(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("GITHUB_TOKEN", "secret-token")
monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", "secret-webhook")
monkeypatch.setenv("ROBOMP_GH_PROXY_HMAC_KEY", "secret-proxy")
monkeypatch.setenv("BUN_INSTALL_CACHE_DIR", "/data/cache/bun-cache")
bindings, loop, thread = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)), slot_uid=2001)
try:
env = host_tools._repo_command_env(bindings)
finally:
_stop_loop(loop, thread)
assert env["GITHUB_TOKEN"] == ""
assert env["GITHUB_WEBHOOK_SECRET"] == ""
assert env["ROBOMP_GH_PROXY_HMAC_KEY"] == ""
assert env["BUN_INSTALL_CACHE_DIR"] == str(bindings.workspace.root / ".omp-xdg" / "cache" / "bun-install")
assert env["XDG_CACHE_HOME"] == str(bindings.workspace.root / ".omp-xdg" / "cache")
assert env["TMPDIR"] == str(bindings.workspace.root / ".omp-tmp")
assert env["GIT_CONFIG_COUNT"] == "1"
assert env["GIT_CONFIG_KEY_0"] == "safe.directory"
assert env["GIT_CONFIG_VALUE_0"] == str(bindings.workspace.repo_dir)
assert env["GIT_AUTHOR_NAME"] == bindings.author_name
assert env["GIT_AUTHOR_EMAIL"] == bindings.author_email
assert env["GIT_COMMITTER_NAME"] == bindings.author_name
assert env["GIT_COMMITTER_EMAIL"] == bindings.author_email
assert (bindings.workspace.root / ".omp-tmp").is_dir()
def test_run_repo_command_uses_slot_identity_kwargs(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import subprocess
bindings, loop, thread = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)), slot_uid=2001)
captured: dict[str, Any] = {}
monkeypatch.setattr(
host_tools,
"_slot_subprocess_kwargs",
lambda uid: {"user": uid, "group": uid, "extra_groups": [2000], "umask": 0o002},
)
def fake_run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
captured["cmd"] = cmd
captured["kwargs"] = kwargs
return subprocess.CompletedProcess(cmd, 0, "ok", "")
monkeypatch.setattr(host_tools.subprocess, "run", fake_run) # type: ignore[attr-defined]
try:
proc = host_tools._run_repo_command(bindings, ["git", "status"])
finally:
_stop_loop(loop, thread)
assert proc.stdout == "ok"
assert captured["cmd"] == ["git", "status"]
kwargs = captured["kwargs"]
assert kwargs["cwd"] == str(bindings.workspace.repo_dir)
assert kwargs["user"] == 2001
assert kwargs["group"] == 2001
assert kwargs["extra_groups"] == [2000]
assert kwargs["umask"] == 0o002
assert kwargs["env"]["BUN_INSTALL_CACHE_DIR"].endswith("/.omp-xdg/cache/bun-install")
def _write_bun_repo(repo_dir: Path) -> None:
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / "package.json").write_text('{"name":"x"}', encoding="utf-8")
(repo_dir / "bun.lock").write_text("{}", encoding="utf-8")
def test_ensure_workspace_dependencies_installs_when_missing(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import subprocess
bindings, loop, thread = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
_write_bun_repo(bindings.workspace.repo_dir)
captured: list[tuple[str, ...]] = []
def fake_run_repo_command(
_b: ToolBindings, cmd: list[str] | tuple[str, ...], *, timeout: float | None = None
) -> subprocess.CompletedProcess[str]:
captured.append(tuple(cmd))
return subprocess.CompletedProcess(list(cmd), 0, "449 packages installed", "")
monkeypatch.setattr(host_tools, "_run_repo_command", fake_run_repo_command)
try:
host_tools.ensure_workspace_dependencies(bindings)
finally:
_stop_loop(loop, thread)
assert captured == [("bun", "install", "--frozen-lockfile", "--ignore-scripts")]
def test_ensure_workspace_dependencies_reinstalls_when_node_modules_present(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A bare node_modules/ dir is NOT a "fully installed" sentinel: a prior
# install that timed out or crashed half-way leaves a partial tree. The
# frozen install must still run so bun re-links anything missing, instead
# of skipping forever and leaving the resolver broken.
import subprocess
bindings, loop, thread = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
_write_bun_repo(bindings.workspace.repo_dir)
(bindings.workspace.repo_dir / "node_modules").mkdir()
captured: list[tuple[str, ...]] = []
def fake_run_repo_command(
_b: ToolBindings, cmd: list[str] | tuple[str, ...], *, timeout: float | None = None
) -> subprocess.CompletedProcess[str]:
captured.append(tuple(cmd))
return subprocess.CompletedProcess(list(cmd), 0, "Checked 449 packages", "")
monkeypatch.setattr(host_tools, "_run_repo_command", fake_run_repo_command)
try:
host_tools.ensure_workspace_dependencies(bindings)
finally:
_stop_loop(loop, thread)
assert captured == [("bun", "install", "--frozen-lockfile", "--ignore-scripts")]
def test_ensure_workspace_dependencies_skips_non_bun_repo(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# repo_dir exists (created by _stub_workspace) but has no package.json/bun.lock.
bindings, loop, thread = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
called = False
def fail_run(*_a: Any, **_k: Any) -> Any:
nonlocal called
called = True
raise AssertionError("must not install in a non-bun repo")
monkeypatch.setattr(host_tools, "_run_repo_command", fail_run)
try:
host_tools.ensure_workspace_dependencies(bindings)
finally:
_stop_loop(loop, thread)
assert called is False
def test_ensure_workspace_dependencies_swallows_install_failure(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import subprocess
bindings, loop, thread = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
_write_bun_repo(bindings.workspace.repo_dir)
def failing_run(
_b: ToolBindings, cmd: list[str] | tuple[str, ...], *, timeout: float | None = None
) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(list(cmd), 1, "", "lockfile out of date")
monkeypatch.setattr(host_tools, "_run_repo_command", failing_run)
try:
# A stale frozen lockfile (e.g. a PR that bumped deps) must not raise —
# the agent can still install itself or report the gap.
host_tools.ensure_workspace_dependencies(bindings)
finally:
_stop_loop(loop, thread)
assert not (bindings.workspace.repo_dir / "node_modules").exists()
def test_guarded_push_branch_rev_parse_runs_via_repo_command_and_passes_slot_uid(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import subprocess
from dataclasses import replace
from robomp.git_ops import PushResult
class RecordingTransport:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def push_branch(self, **kwargs: Any) -> PushResult:
self.calls.append(kwargs)
return PushResult(head=str(kwargs["expected_head"]), branch=str(kwargs["branch"]))
transport = RecordingTransport()
bindings, loop, thread = _bindings(
db,
tmp_path,
httpx.MockTransport(lambda _r: httpx.Response(500)),
slot_uid=2001,
)
bindings = replace(bindings, git_transport=transport)
commands: list[list[str]] = []
def fake_run_repo_command(
command_bindings: ToolBindings, cmd: list[str] | tuple[str, ...], *, timeout: float | None = None
) -> subprocess.CompletedProcess[str]:
del timeout
assert command_bindings.slot_uid == 2001
command = list(cmd)
commands.append(command)
if command == ["git", "rev-parse", "HEAD"]:
return subprocess.CompletedProcess(command, 0, "abc123\n", "")
if command[:3] == ["git", "log", "--format=%H%x09%ae%x09%an"]:
return subprocess.CompletedProcess(
command,
0,
"abc123\trobomp-bot@example.invalid\trobomp-bot\n",
"",
)
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr(host_tools, "_run_repo_command", fake_run_repo_command)
monkeypatch.setattr(host_tools, "_share_git_metadata_with_slots", lambda _repo_dir, _slot_uid: None)
try:
head = host_tools._guarded_push_branch(bindings, {}, "gh_push_branch", bindings.workspace.branch)
finally:
_stop_loop(loop, thread)
assert head == "abc123"
assert ["git", "rev-parse", "HEAD"] in commands
assert transport.calls == [
{
"repo": "octo/widget",
"workspace_key": "octo__widget__42",
"repo_dir": bindings.workspace.repo_dir,
"branch": bindings.workspace.branch,
"expected_head": "abc123",
"slot_uid": 2001,
}
]
def test_gh_post_comment_happy_path(db: Database, tmp_path: Path) -> None:
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = json.loads(request.content)
captured["auth"] = request.headers.get("authorization")
return httpx.Response(201, json={"id": 999, "user": {"login": "robomp-bot"}, "body": "hi", "created_at": "t"})
transport = httpx.MockTransport(handler)
bindings, loop, t = _bindings(db, tmp_path, transport)
try:
tool = next(x for x in build(bindings) if x.name == "gh_post_comment")
result = tool.execute({"body": "hi"}, _ctx())
finally:
_stop_loop(loop, t)
assert result.startswith("comment posted")
assert captured["url"].endswith("/repos/octo/widget/issues/42/comments")
assert captured["body"] == {"body": "hi"}
assert captured["auth"] == "Bearer token"
def test_gh_post_comment_validates_body(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 == "gh_post_comment")
with pytest.raises(RpcCommandError):
tool.execute({"body": ""}, _ctx())
finally:
_stop_loop(loop, t)
def test_gh_post_comment_defaults_to_inbound_pr_thread(db: Database, tmp_path: Path) -> None:
"""PR conversation/review tasks set inbound_thread_number to the PR; the
agent's reply must land on that PR by default, not the originating issue."""
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(201, json={"id": 7, "user": {"login": "robomp-bot"}, "body": "hi", "created_at": "t"})
transport = httpx.MockTransport(handler)
github = GitHubClient("token", transport=transport)
loop, thread = _make_loop_in_background()
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=_stub_issue(), # issue #42
workspace=_stub_workspace(tmp_path),
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
inbound_thread_number=99, # PR #99 that fixes issue #42
)
try:
tool = next(x for x in build(bindings) if x.name == "gh_post_comment")
tool.execute({"body": "hi"}, _ctx())
finally:
_stop_loop(loop, thread)
assert captured["url"].endswith("/repos/octo/widget/issues/99/comments"), captured["url"]
def test_gh_post_comment_explicit_number_overrides_inbound(db: Database, tmp_path: Path) -> None:
"""An explicit `number` arg still wins over the inbound default."""
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(201, json={"id": 7, "user": {"login": "robomp-bot"}, "body": "hi", "created_at": "t"})
transport = httpx.MockTransport(handler)
github = GitHubClient("token", transport=transport)
loop, thread = _make_loop_in_background()
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=_stub_issue(),
workspace=_stub_workspace(tmp_path),
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
inbound_thread_number=99,
)
try:
tool = next(x for x in build(bindings) if x.name == "gh_post_comment")
tool.execute({"body": "hi", "number": 42}, _ctx())
finally:
_stop_loop(loop, thread)
assert captured["url"].endswith("/repos/octo/widget/issues/42/comments"), captured["url"]
def test_gh_post_comment_propagates_github_error(db: Database, tmp_path: Path) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(422, json={"message": "Validation failed"}))
bindings, loop, t = _bindings(db, tmp_path, transport)
try:
tool = next(x for x in build(bindings) if x.name == "gh_post_comment")
with pytest.raises(RpcCommandError) as exc:
tool.execute({"body": "hi"}, _ctx())
assert "422" in str(exc.value)
finally:
_stop_loop(loop, t)
def test_gh_open_pr_requires_template_sections(db: Database, tmp_path: Path) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(500))
bindings, loop, t = _bindings(db, tmp_path, transport)
db.set_issue_classification(bindings.issue_key, "bug")
try:
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
with pytest.raises(RpcCommandError) as exc:
tool.execute({"title": "t", "body": "no sections"}, _ctx())
assert "Repro" in str(exc.value)
finally:
_stop_loop(loop, t)
def test_repro_record_writes_transcript(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 == "repro_record")
result = tool.execute(
{
"title": "panic on empty input",
"command": "bun test foo.test.ts",
"output": "Error: boom",
"exit_code": 1,
"reproduced": True,
},
_ctx(),
)
assert result == "recorded"
files = list(bindings.workspace.repro_dir.iterdir())
assert len(files) == 1
assert "exit_code: 1" in files[0].read_text()
finally:
_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)),
raising=False,
)
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda r: httpx.Response(500)), slot_uid=2001)
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(),
)
assert result == "recorded"
files = list(bindings.workspace.repro_dir.iterdir())
assert len(files) == 1
assert chowns == [(files[0], 2001, 2001)]
finally:
_stop_loop(loop, t)
def test_repro_record_rejects_bad_args(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 == "repro_record")
with pytest.raises(RpcCommandError):
tool.execute({"title": "", "command": "x", "output": "y", "exit_code": 1}, _ctx())
with pytest.raises(RpcCommandError):
tool.execute({"title": "t", "command": "x", "output": "y", "exit_code": "bad"}, _ctx())
finally:
_stop_loop(loop, t)
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:
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:
tool = next(x for x in build(bindings) if x.name == "mark_unable_to_reproduce")
result = tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx())
finally:
_stop_loop(loop, t)
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 == "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:
# Any HTTP call is a regression: abort_task MUST NOT touch GitHub.
def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"abort_task issued an HTTP request to {request.url}")
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
controller = AbortController()
stops: list[None] = []
controller.stop = lambda: stops.append(None)
# Frozen dataclass — rebuild with the controller attached.
bindings = ToolBindings(
db=bindings.db,
github=bindings.github,
git_transport=bindings.git_transport,
repo=bindings.repo,
issue=bindings.issue,
workspace=bindings.workspace,
loop=bindings.loop,
author_name=bindings.author_name,
author_email=bindings.author_email,
settings=bindings.settings,
inbound_thread_number=bindings.inbound_thread_number,
inbound_is_pr=bindings.inbound_is_pr,
slot_uid=bindings.slot_uid,
abort=controller,
)
try:
tool = next(x for x in build(bindings) if x.name == "abort_task")
result = tool.execute({"reason": "ref dir owned by foreign uid; git commit cannot lock HEAD"}, _ctx())
finally:
_stop_loop(loop, t)
assert result == "aborted"
assert controller.triggered
assert "foreign uid" in controller.reason
assert len(stops) == 1, "stop callback must fire exactly once"
issue = db.get_issue(bindings.issue_key)
assert issue and issue.state == "abandoned"
# Audit row records the call. Use raw SQL because `Database` exposes a
# writer but no reader for `tool_calls` — the dashboard reads via SQL too.
with db._lock: # noqa: SLF001 - test-only inspection
row = db._conn.execute( # noqa: SLF001
"SELECT tool, args_json FROM tool_calls WHERE issue_key=? AND tool=?",
(bindings.issue_key, "abort_task"),
).fetchone()
assert row is not None
assert "foreign uid" in row["args_json"]
def test_abort_task_rejects_empty_reason(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 == "abort_task")
with pytest.raises(RpcCommandError):
tool.execute({"reason": " "}, _ctx())
finally:
_stop_loop(loop, t)
# No state change on rejected validation.
issue = db.get_issue(bindings.issue_key)
assert issue and issue.state == "reproducing"
def test_abort_task_signal_is_idempotent(db: Database, tmp_path: Path) -> None:
controller = AbortController()
fires: list[None] = []
controller.stop = lambda: fires.append(None)
controller.signal("first")
controller.signal("second")
assert controller.triggered
assert controller.reason == "first" # second call must not overwrite
assert len(fires) == 1, "stop must not be called again after the first abort"
def test_fetch_issue_thread_returns_markdown(db: Database, tmp_path: Path) -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/comments"):
return httpx.Response(
200,
json=[
{"id": 1, "user": {"login": "alice"}, "body": "still broken", "created_at": "t1"},
],
)
return httpx.Response(
200,
json={
"number": 42,
"title": "boom",
"body": "b",
"state": "open",
"user": {"login": "alice"},
"labels": [{"name": "bug"}],
},
)
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
try:
tool = next(x for x in build(bindings) if x.name == "fetch_issue_thread")
result = tool.execute({}, _ctx())
finally:
_stop_loop(loop, t)
assert "octo/widget#42" in result
assert "@alice" in result
assert "still broken" in result
def test_classify_issue_applies_labels_and_persists_primary(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": n} for n in captured["body"]["labels"]],
)
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
result = tool.execute(
{
"primary": "bug",
"priority": "prio:p1",
"functional": ["tool", "agent"],
"provider": "provider:openai",
"platform": "platform:macos",
"rationale": "tool call panics on empty arg on macOS",
},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert "classified as bug" in result
assert "reproduce" in result.lower()
assert captured["path"].endswith("/issues/42/labels")
assert captured["body"]["labels"] == [
"bug",
"prio:p1",
"tool",
"agent",
"providers",
"provider:openai",
"platform:macos",
"triaged",
]
row = db.get_issue(bindings.issue_key)
assert row is not None and row.classification == "bug"
def test_classify_issue_question_skips_repro_path(db: Database, tmp_path: Path) -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[{"name": "question"}, {"name": "triaged"}]))
bindings, loop, t = _bindings(db, tmp_path, transport)
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
result = tool.execute(
{"primary": "question", "rationale": "how-to about config"},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert "question" in result
assert "no PR" in result
row = db.get_issue(bindings.issue_key)
assert row is not None and row.classification == "question"
def test_classify_issue_rejects_bug_without_priority(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 == "classify_issue")
with pytest.raises(RpcCommandError):
tool.execute({"primary": "bug", "rationale": "yes a bug"}, _ctx())
finally:
_stop_loop(loop, t)
def test_classify_issue_drops_priority_on_non_bug(db: Database, tmp_path: Path) -> None:
"""Non-bug primaries silently drop a stray `priority` rather than rejecting.
Some models treat every tool-schema property as required and would loop
forever if a non-empty optional value triggered a hard validation error.
"""
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = json.loads(request.content)
return httpx.Response(200, json=[{"name": "question"}, {"name": "triaged"}])
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
result = tool.execute(
{"primary": "question", "priority": "prio:p3", "rationale": "how-to"},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert "question" in result
# priority must NOT be applied as a label on non-bug classifications.
assert "prio:p3" not in (captured["body"].get("labels") or [])
row = db.get_issue(bindings.issue_key)
assert row is not None and row.classification == "question"
def test_classify_issue_rejects_unknown_primary(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 == "classify_issue")
with pytest.raises(RpcCommandError):
tool.execute({"primary": "nonsense", "rationale": "x"}, _ctx())
finally:
_stop_loop(loop, t)
def _pr_bindings(
db: Database, tmp_path: Path, transport: httpx.MockTransport
) -> tuple[ToolBindings, asyncio.AbstractEventLoop, threading.Thread]:
"""Same as _bindings but with `inbound_is_pr=True` — webhook arrived on a PR."""
github = GitHubClient("token", transport=transport)
loop, thread = _make_loop_in_background()
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=_stub_issue(),
workspace=_stub_workspace(tmp_path),
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
inbound_thread_number=99,
inbound_is_pr=True,
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="opened",
branch=bindings.workspace.branch,
session_dir=str(bindings.workspace.session_dir),
pr_number=99,
)
db.set_issue_classification(bindings.issue_key, "bug")
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 == []
@pytest.mark.parametrize("classification", ["enhancement", "proposal"])
def test_impl_gate_rejects_unauthorized_non_auto_classification_before_repo_commands(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, classification: str
) -> 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 before implementation authorization")
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
db.set_issue_classification(bindings.issue_key, classification)
monkeypatch.setattr(host_tools, "_run_repo_command", record_repo_command)
try:
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) as push_exc:
push.execute({}, _ctx())
with pytest.raises(RpcCommandError) as pr_exc:
open_pr.execute({"title": "fix: x", "body": "invalid"}, _ctx())
finally:
_stop_loop(loop, t)
for msg in (str(push_exc.value), str(pr_exc.value)):
assert f"classified `{classification}`" in msg
assert "OWNER or allowlisted maintainer" in msg
assert "gh_post_comment" in msg
assert calls == []
rows = db._conn.execute(
"SELECT tool, error FROM tool_calls WHERE tool IN ('gh_push_branch', 'gh_open_pr') ORDER BY id"
).fetchall()
assert [row["tool"] for row in rows] == ["gh_push_branch", "gh_open_pr"]
assert all(f"classified `{classification}`" in row["error"] for row in rows)
@pytest.mark.parametrize("classification", ["enhancement", "proposal"])
def test_impl_gate_allows_authorized_non_auto_classification_to_reach_pr_validation(
db: Database, tmp_path: Path, classification: str
) -> None:
from dataclasses import replace
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
db.set_issue_classification(bindings.issue_key, classification)
bindings = replace(bindings, impl_authorized=True)
try:
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
with pytest.raises(RpcCommandError) as exc:
tool.execute({"title": "fix: x", "body": ""}, _ctx())
finally:
_stop_loop(loop, t)
msg = str(exc.value)
assert "requires a non-empty 'body'" in msg
assert "OWNER or allowlisted maintainer" not in msg
@pytest.mark.parametrize("classification", ["enhancement", "proposal"])
def test_impl_gate_allows_authorized_non_auto_classification_push_to_reach_repo_commands(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, classification: str
) -> None:
from dataclasses import replace
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 RuntimeError("authorized non-auto issue reached gh_push_branch repo command")
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
db.set_issue_classification(bindings.issue_key, classification)
bindings = replace(bindings, impl_authorized=True)
monkeypatch.setattr(host_tools, "_run_repo_command", record_repo_command)
try:
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RuntimeError, match="authorized non-auto issue reached gh_push_branch repo command"):
tool.execute({}, _ctx())
finally:
_stop_loop(loop, t)
assert calls
def test_impl_gate_allows_later_authorized_event_to_reach_repo_commands(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> 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 RuntimeError("later authorized event reached gh_push_branch repo command")
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
db.set_issue_classification(bindings.issue_key, "enhancement")
db.record_event(
delivery_id="auth-event",
event_type="issue_comment",
repo=bindings.issue.repo,
issue_key=bindings.issue_key,
payload={
"_robomp_directive": {
"body": "go ahead",
"author": "can1357",
"pragmas": [],
"authorizes_impl": True,
}
},
)
monkeypatch.setattr(host_tools, "_run_repo_command", record_repo_command)
try:
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RuntimeError, match="later authorized event reached gh_push_branch repo command"):
tool.execute({}, _ctx())
finally:
_stop_loop(loop, t)
assert calls
def test_impl_gate_ignores_skipped_authorized_event(db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> 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("skipped authorization must not reach repo command")
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
db.set_issue_classification(bindings.issue_key, "enhancement")
db.record_event(
delivery_id="skipped-auth-event",
event_type="issue_comment",
repo=bindings.issue.repo,
issue_key=bindings.issue_key,
payload={
"_robomp_directive": {
"body": "go ahead",
"author": "can1357",
"pragmas": [],
"authorizes_impl": True,
}
},
state="skipped",
)
monkeypatch.setattr(host_tools, "_run_repo_command", record_repo_command)
try:
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RpcCommandError) as exc:
tool.execute({}, _ctx())
finally:
_stop_loop(loop, t)
assert "OWNER or allowlisted maintainer" in str(exc.value)
assert calls == []
def test_impl_gate_allows_bug_without_directive_to_reach_pr_validation(db: Database, tmp_path: Path) -> None:
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
db.set_issue_classification(bindings.issue_key, "bug")
try:
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
with pytest.raises(RpcCommandError) as exc:
tool.execute({"title": "fix: x", "body": ""}, _ctx())
finally:
_stop_loop(loop, t)
msg = str(exc.value)
assert "requires a non-empty 'body'" in msg
assert "OWNER or allowlisted maintainer" not in msg
def test_impl_gate_allows_existing_proposal_pr_to_reach_pr_validation(db: Database, tmp_path: Path) -> None:
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
db.set_issue_classification(bindings.issue_key, "proposal")
db.set_issue_pr(bindings.issue_key, 7)
try:
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
with pytest.raises(RpcCommandError) as exc:
tool.execute({"title": "fix: x", "body": ""}, _ctx())
finally:
_stop_loop(loop, t)
msg = str(exc.value)
assert "requires a non-empty 'body'" in msg
assert "OWNER or allowlisted maintainer" not in msg
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] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return httpx.Response(500)
bindings, loop, t = _pr_bindings(db, tmp_path, httpx.MockTransport(handler))
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
result = tool.execute(
{"primary": "documentation", "rationale": "docs only"},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert "no-op" in result.lower()
assert calls == [] # no GitHub label mutation
# Classification must remain whatever it was before — not overwritten.
row = db.get_issue(bindings.issue_key)
assert row is not None and row.classification == "bug"
def test_classify_issue_already_classified_is_noop(db: Database, tmp_path: Path) -> None:
"""Re-classifying an already-classified issue is rejected without GitHub side effects."""
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return httpx.Response(500)
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
db.set_issue_classification(bindings.issue_key, "bug")
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
result = tool.execute(
{"primary": "question", "rationale": "actually a question"},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert "no-op" in result.lower()
assert "already classified" in result.lower()
assert calls == []
row = db.get_issue(bindings.issue_key)
assert row is not None and row.classification == "bug" # unchanged
def test_set_issue_labels_on_pr_thread_is_noop(db: Database, tmp_path: Path) -> None:
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return httpx.Response(500)
bindings, loop, t = _pr_bindings(db, tmp_path, httpx.MockTransport(handler))
try:
tool = next(x for x in build(bindings) if x.name == "set_issue_labels")
result = tool.execute({"labels": ["wontfix"]}, _ctx())
finally:
_stop_loop(loop, t)
assert "no-op" in result.lower()
assert calls == []
def _init_git_repo(repo_dir: Path, branch: str) -> None:
"""Initialize a minimal git repo at `repo_dir` with `branch` checked out."""
import os as _os
import subprocess as _sp
repo_dir.mkdir(parents=True, exist_ok=True)
_sp.run(
["git", "init", f"--initial-branch={branch}", str(repo_dir)],
check=True,
capture_output=True,
text=True,
)
(repo_dir / "README.md").write_text("hi\n", encoding="utf-8")
_sp.run(["git", "-C", str(repo_dir), "add", "."], check=True, capture_output=True, text=True)
_sp.run(
["git", "commit", "-m", "init"],
cwd=str(repo_dir),
check=True,
capture_output=True,
text=True,
env=_os.environ
| {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
},
)
def test_classify_issue_renames_branch_when_slug_provided(db: Database, tmp_path: Path) -> None:
bindings, loop, t = _bindings(
db,
tmp_path,
httpx.MockTransport(
lambda r: httpx.Response(200, json=[{"name": "bug"}, {"name": "prio:p1"}, {"name": "triaged"}])
),
)
# The stub workspace's initial branch matches `_stub_workspace`.
_init_git_repo(bindings.workspace.repo_dir, bindings.workspace.branch)
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
result = tool.execute(
{
"primary": "bug",
"priority": "prio:p1",
"rationale": "powershell env colon-var parsing on win is broken",
"branch_slug": "fix-windows-env-colon-vars",
},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert "branch renamed to" in result.lower()
assert bindings.workspace.branch == "farm/abc12345/fix-windows-env-colon-vars"
row = db.get_issue(bindings.issue_key)
assert row is not None and row.branch == "farm/abc12345/fix-windows-env-colon-vars"
import subprocess as _sp
head = _sp.run(
["git", "symbolic-ref", "HEAD"],
cwd=str(bindings.workspace.repo_dir),
check=True,
capture_output=True,
text=True,
).stdout.strip()
assert head == "refs/heads/farm/abc12345/fix-windows-env-colon-vars"
def test_classify_issue_rejects_invalid_branch_slug(db: Database, tmp_path: Path) -> None:
"""Bad slug is rejected BEFORE GitHub is contacted (no labels applied)."""
requests: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(str(request.url))
return httpx.Response(500)
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
with pytest.raises(RpcCommandError):
tool.execute(
{
"primary": "bug",
"priority": "prio:p1",
"rationale": "x",
"branch_slug": "Has-Caps",
},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert requests == [] # no GitHub call attempted
# Branch unchanged; issue row unchanged.
assert bindings.workspace.branch == "farm/abc12345/some-issue"
def test_classify_issue_rename_failure_does_not_apply_labels_or_classification(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
requests: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(str(request.url))
return httpx.Response(200, json=[])
def fail_rename(*_args: object, **_kwargs: object) -> str:
raise host_tools.GitCommandError(["git", "branch", "-m"], 128, "", "fatal: detected dubious ownership")
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
monkeypatch.setattr(host_tools, "rename_workspace_branch", fail_rename)
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
with pytest.raises(RpcCommandError):
tool.execute(
{
"primary": "bug",
"priority": "prio:p1",
"rationale": "x",
"branch_slug": "fix-orphan-tool-output",
},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert requests == []
row = db.get_issue(bindings.issue_key)
assert row is not None
assert row.classification is None
assert row.branch == "farm/abc12345/some-issue"
assert bindings.workspace.branch == "farm/abc12345/some-issue"
def test_classify_issue_omitting_branch_slug_is_a_noop(db: Database, tmp_path: Path) -> None:
"""Existing callers that don't pass branch_slug must keep the original branch."""
bindings, loop, t = _bindings(
db,
tmp_path,
httpx.MockTransport(lambda r: httpx.Response(200, json=[{"name": "question"}, {"name": "triaged"}])),
)
try:
tool = next(x for x in build(bindings) if x.name == "classify_issue")
result = tool.execute(
{"primary": "question", "rationale": "how-to"},
_ctx(),
)
finally:
_stop_loop(loop, t)
assert "branch renamed" not in result.lower()
assert bindings.workspace.branch == "farm/abc12345/some-issue"
def test_set_issue_labels_appends(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=[{"name": n} for n in captured["body"]["labels"]])
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
try:
tool = next(x for x in build(bindings) if x.name == "set_issue_labels")
result = tool.execute({"labels": ["wontfix"]}, _ctx())
finally:
_stop_loop(loop, t)
assert "wontfix" in result
assert captured["body"]["labels"] == ["wontfix"]
def test_set_issue_labels_rejects_empty(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 == "set_issue_labels")
with pytest.raises(RpcCommandError):
tool.execute({"labels": []}, _ctx())
with pytest.raises(RpcCommandError):
tool.execute({"labels": [" ", ""]}, _ctx())
finally:
_stop_loop(loop, t)
def test_gh_push_branch_rejects_wrong_identity(db: Database, tmp_path: Path) -> None:
"""Pre-push gate refuses to push commits authored by anyone other than the configured identity."""
import os
import subprocess
# Build a real local upstream + worktree so git operations actually work.
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "seed",
"GIT_AUTHOR_EMAIL": "seed@x",
"GIT_COMMITTER_NAME": "seed",
"GIT_COMMITTER_EMAIL": "seed@x",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
["git", "-C", str(seed), "-c", "user.email=seed@x", "-c", "user.name=seed", "commit", "-m", "init"],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="identity test",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
# Commit with a different identity to provoke the gate.
bad_env = os.environ | {
"GIT_AUTHOR_NAME": "wrong",
"GIT_AUTHOR_EMAIL": "wrong@nope",
"GIT_COMMITTER_NAME": "wrong",
"GIT_COMMITTER_EMAIL": "wrong@nope",
}
(ws.repo_dir / "x.txt").write_text("hi\n")
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "."], check=True, capture_output=True)
subprocess.run(
["git", "-C", str(ws.repo_dir), "-c", "user.email=wrong@nope", "-c", "user.name=wrong", "commit", "-m", "bad"],
check=True,
capture_output=True,
env=bad_env,
)
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RpcCommandError) as exc:
tool.execute({}, _ctx())
msg = str(exc.value)
assert "identity mismatch" in msg
assert "wrong <wrong@nope>" in msg
assert "robomp-bot <robomp-bot@example.invalid>" in msg
# Branch must NOT have been pushed.
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert not any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines()), refs.stdout
finally:
_stop_loop(loop, thread)
def test_gh_open_pr_rejects_wrong_identity_before_push_or_pr(db: Database, tmp_path: Path) -> None:
"""gh_open_pr uses the guarded push path before creating the pull request."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "seed",
"GIT_AUTHOR_EMAIL": "seed@x",
"GIT_COMMITTER_NAME": "seed",
"GIT_COMMITTER_EMAIL": "seed@x",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
["git", "-C", str(seed), "-c", "user.email=seed@x", "-c", "user.name=seed", "commit", "-m", "init"],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="identity test",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
bad_env = os.environ | {
"GIT_AUTHOR_NAME": "wrong",
"GIT_AUTHOR_EMAIL": "wrong@nope",
"GIT_COMMITTER_NAME": "wrong",
"GIT_COMMITTER_EMAIL": "wrong@nope",
}
(ws.repo_dir / "x.txt").write_text("hi\n")
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "."], check=True, capture_output=True)
subprocess.run(
["git", "-C", str(ws.repo_dir), "-c", "user.email=wrong@nope", "-c", "user.name=wrong", "commit", "-m", "bad"],
check=True,
capture_output=True,
env=bad_env,
)
opened_pr = False
def handler(_request: httpx.Request) -> httpx.Response:
nonlocal opened_pr
opened_pr = True
return httpx.Response(
201,
json={
"number": 7,
"html_url": "https://github.com/octo/widget/pull/7",
"head": {"ref": ws.branch},
"base": {"ref": "main"},
},
)
github = GitHubClient("tok", transport=httpx.MockTransport(handler))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n\nFixes #42\n"
with pytest.raises(RpcCommandError) as exc:
tool.execute({"title": "fix: x", "body": body}, _ctx())
assert "identity mismatch" in str(exc.value)
assert not opened_pr
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert not any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines()), refs.stdout
finally:
_stop_loop(loop, thread)
def test_gh_push_branch_rejects_invalid_identity_scan_range(db: Database, tmp_path: Path) -> None:
"""A failing git-log author scan is a push rejection, not an empty successful scan."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="missing base ref",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
subprocess.run(
["git", "-C", str(ws.repo_dir), "update-ref", "-d", "refs/remotes/origin/main"], check=True, capture_output=True
)
(ws.repo_dir / "x.txt").write_text("hi\n")
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "x.txt"], check=True, capture_output=True)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"ok",
],
check=True,
capture_output=True,
env=env,
)
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RpcCommandError) as exc:
tool.execute({}, _ctx())
msg = str(exc.value)
assert "could not inspect commit authors" in msg
assert "origin/main..HEAD" in msg
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert not any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines()), refs.stdout
row = db._conn.execute(
"SELECT error FROM tool_calls WHERE tool='gh_push_branch' ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None and "could not inspect commit authors" in row["error"]
assert "origin/main..HEAD" in row["error"]
finally:
_stop_loop(loop, thread)
def test_gh_open_pr_requires_closes_keyword(db: Database, tmp_path: Path) -> None:
"""gh_open_pr refuses if the body has the four sections but no Fixes/Closes/Resolves keyword."""
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda r: httpx.Response(500)))
db.set_issue_classification(bindings.issue_key, "bug")
try:
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n"
with pytest.raises(RpcCommandError) as exc:
tool.execute({"title": "fix: x", "body": body}, _ctx())
assert "Fixes #42" in str(exc.value)
finally:
_stop_loop(loop, t)
def test_gh_open_pr_refuses_failed_bun_check_before_push_or_pr(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""gh_open_pr sends a failing pre-PR check back to the agent without creating a PR."""
import os
opened_pr = False
def handler(_request: httpx.Request) -> httpx.Response:
nonlocal opened_pr
opened_pr = True
return httpx.Response(
201,
json={
"number": 7,
"html_url": "https://github.com/octo/widget/pull/7",
"head": {"ref": "farm/abc12345/some-issue"},
"base": {"ref": "main"},
},
)
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
db.set_issue_classification(bindings.issue_key, "bug")
fakebin = tmp_path / "fakebin"
fakebin.mkdir()
fake_bun = fakebin / "bun"
fake_bun.write_text(
"#!/bin/sh\n"
'if [ "$1" != "check" ]; then printf "wrong command: %s\\n" "$1" >&2; exit 2; fi\n'
'printf "TypeError: property missing\\n" >&2\n'
"exit 1\n",
encoding="utf-8",
)
fake_bun.chmod(0o755)
monkeypatch.setenv("PATH", f"{fakebin}{os.pathsep}{os.environ['PATH']}")
(bindings.workspace.repo_dir / "package.json").write_text(
json.dumps({"scripts": {"check": "tsc --noEmit"}}) + "\n",
encoding="utf-8",
)
try:
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n\nFixes #42\n"
with pytest.raises(RpcCommandError) as exc:
tool.execute({"title": "fix: x", "body": body}, _ctx())
finally:
_stop_loop(loop, t)
msg = str(exc.value)
assert "refusing to open PR" in msg
assert "`bun check` failed before open PR" in msg
assert "TypeError: property missing" in msg
assert not opened_pr
row = db._conn.execute("SELECT error FROM tool_calls WHERE tool='gh_open_pr' ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert "TypeError: property missing" in row["error"]
def test_gh_push_branch_rejects_dirty_worktree(db: Database, tmp_path: Path) -> None:
"""Pre-push gate refuses if the working tree has uncommitted changes."""
import os
import subprocess
# Real upstream + worktree so git status works.
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="dirty test",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
# Make a proper commit (so the identity gate passes).
(ws.repo_dir / "a.txt").write_text("a\n")
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "a.txt"], check=True, capture_output=True)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"ok",
],
check=True,
capture_output=True,
env=env,
)
# Now dirty the worktree — uncommitted edit.
(ws.repo_dir / "a.txt").write_text("a-modified\n")
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RpcCommandError) as exc:
tool.execute({}, _ctx())
assert "working tree is dirty" in str(exc.value)
# Nothing pushed.
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert not any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines()), refs.stdout
finally:
_stop_loop(loop, thread)
def test_gh_push_branch_runs_fix_and_check_before_pushing(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""gh_push_branch must run `bun run fix` then `bun check` (when defined)
before the push reaches the remote. Same gate as `gh_open_pr` so a
follow-up commit can't break CI."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="push gate",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
fix_calls = tmp_path / "fix-calls"
check_calls = tmp_path / "check-calls"
fakebin = tmp_path / "fakebin"
fakebin.mkdir()
fake_bun = fakebin / "bun"
fake_bun.write_text(
"#!/bin/sh\n"
'if [ "$1" = "run" ] && [ "$2" = "fix" ]; then\n'
f" printf called >> {fix_calls}\n"
' printf "formatted\\n" > src.txt\n'
" exit 0\n"
"fi\n"
'if [ "$1" = "check" ]; then\n'
f" printf called >> {check_calls}\n"
" exit 0\n"
"fi\n"
'printf "unexpected bun call: %s\\n" "$*" >&2\n'
"exit 2\n"
)
fake_bun.chmod(0o755)
monkeypatch.setenv("PATH", f"{fakebin}{os.pathsep}{os.environ['PATH']}")
(ws.repo_dir / "package.json").write_text(
json.dumps({"scripts": {"fix": "...", "check": "..."}}) + "\n",
encoding="utf-8",
)
(ws.repo_dir / "src.txt").write_text("original\n")
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "package.json", "src.txt"], check=True, capture_output=True)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"feat: follow-up",
],
check=True,
capture_output=True,
env=env,
)
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
result = tool.execute({}, _ctx())
finally:
_stop_loop(loop, thread)
# Both gates ran, and fix preceded check (both have one call recorded).
assert fix_calls.read_text() == "called"
assert check_calls.read_text() == "called"
# The formatter's diff was amended into the agent's HEAD commit — no
# standalone `style:` commit; subject and author are retained.
log = subprocess.run(
["git", "-C", str(ws.repo_dir), "log", "--format=%an <%ae> %s", "-n", "2"],
capture_output=True,
text=True,
check=True,
)
lines = log.stdout.strip().splitlines()
assert lines[0] == "robomp-bot <robomp-bot@example.invalid> feat: follow-up", lines
assert lines[1] == "robomp-bot <robomp-bot@example.invalid> init", lines
assert (ws.repo_dir / "src.txt").read_text() == "formatted\n"
# HEAD's tree contains the formatter output (not just the worktree).
show = subprocess.run(
["git", "-C", str(ws.repo_dir), "show", "HEAD:src.txt"],
capture_output=True,
text=True,
check=True,
)
assert show.stdout == "formatted\n"
# And the branch ended up on the remote at the new head.
assert result.startswith(f"pushed {ws.branch} ")
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert f"refs/heads/{ws.branch}" in refs.stdout.splitlines()
def test_gh_push_branch_force_with_lease_recovers_after_amend(db: Database, tmp_path: Path) -> None:
"""A divergent local history (amended commit) must push successfully.
Plain `git push` rejects this as non-fast-forward, leaving the agent stuck.
`--force-with-lease` accepts the rewrite because origin still matches the
ref we last fetched."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="amend recover",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
# First commit + push — fast-forward path.
(ws.repo_dir / "feature.txt").write_text("original\n")
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "feature.txt"], check=True, capture_output=True)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"feat: original",
],
check=True,
capture_output=True,
env=env,
)
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
tool.execute({}, _ctx())
# Confirm origin received the original commit.
first_remote = subprocess.run(
["git", "-C", str(bare), "rev-parse", f"refs/heads/{ws.branch}"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
# Now amend the commit (simulates an agent reset-author rebase, or a
# code change applied via `git commit --amend`).
(ws.repo_dir / "feature.txt").write_text("amended\n")
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "feature.txt"], check=True, capture_output=True)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"--amend",
"--no-edit",
],
check=True,
capture_output=True,
env=env,
)
new_local = subprocess.run(
["git", "-C", str(ws.repo_dir), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert new_local != first_remote, "amend must rewrite the SHA"
# Second push — divergent. Plain `git push` would reject; we expect success.
result = tool.execute({}, _ctx())
finally:
_stop_loop(loop, thread)
assert result.startswith(f"pushed {ws.branch} ")
final_remote = subprocess.run(
["git", "-C", str(bare), "rev-parse", f"refs/heads/{ws.branch}"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert final_remote == new_local, (final_remote, new_local)
def test_gh_push_branch_aborts_on_failed_bun_check(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A failing `bun check` aborts the push and leaves the remote untouched."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="push aborted",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
fakebin = tmp_path / "fakebin"
fakebin.mkdir()
fake_bun = fakebin / "bun"
fake_bun.write_text(
"#!/bin/sh\n"
'if [ "$1" = "check" ]; then\n'
' printf "TypeError: property missing\\n" >&2\n'
" exit 1\n"
"fi\n"
"exit 0\n"
)
fake_bun.chmod(0o755)
monkeypatch.setenv("PATH", f"{fakebin}{os.pathsep}{os.environ['PATH']}")
(ws.repo_dir / "package.json").write_text(
json.dumps({"scripts": {"check": "tsc --noEmit"}}) + "\n",
encoding="utf-8",
)
(ws.repo_dir / "feature.txt").write_text("feature\n")
subprocess.run(
["git", "-C", str(ws.repo_dir), "add", "package.json", "feature.txt"], check=True, capture_output=True
)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"ok",
],
check=True,
capture_output=True,
env=env,
)
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RpcCommandError) as exc:
tool.execute({}, _ctx())
finally:
_stop_loop(loop, thread)
msg = str(exc.value)
assert "refusing to push" in msg
assert "`bun check` failed before push" in msg
assert "TypeError: property missing" in msg
# The branch must not have reached the remote.
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert not any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines()), refs.stdout
# Audit row attributes the failure to gh_push_branch, not gh_open_pr.
row = db._conn.execute(
"SELECT tool, error FROM tool_calls WHERE tool='gh_push_branch' ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None
assert "TypeError: property missing" in row["error"]
def test_gh_push_branch_skip_checks_bypasses_failing_bun_check(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`skip_checks=true` bypasses a failing `bun check` and pushes anyway.
Models the scenario where `main` itself is broken (e.g. an unrelated
formatter/typecheck failure) and the agent has verified that the
failure is pre-existing — re-running the gate forever would never
succeed.
"""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="skip checks",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
fakebin = tmp_path / "fakebin"
fakebin.mkdir()
bun_invocations = fakebin / "bun.log"
fake_bun = fakebin / "bun"
fake_bun.write_text(
"#!/bin/sh\n"
f'echo "$@" >> "{bun_invocations}"\n'
# Both `fix` and `check` would fail — but skip_checks must short-circuit
# so this script is never invoked for them.
"exit 1\n"
)
fake_bun.chmod(0o755)
monkeypatch.setenv("PATH", f"{fakebin}{os.pathsep}{os.environ['PATH']}")
(ws.repo_dir / "package.json").write_text(
json.dumps({"scripts": {"fix": "ruff format", "check": "tsc --noEmit"}}) + "\n",
encoding="utf-8",
)
(ws.repo_dir / "feature.txt").write_text("feature\n")
subprocess.run(
["git", "-C", str(ws.repo_dir), "add", "package.json", "feature.txt"], check=True, capture_output=True
)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"ok",
],
check=True,
capture_output=True,
env=env,
)
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
result = tool.execute({"skip_checks": True}, _ctx())
finally:
_stop_loop(loop, thread)
assert "pushed" in result
assert "pre-push checks skipped" in result
# Bun was never invoked — both `fix` and `check` were short-circuited.
assert not bun_invocations.exists(), bun_invocations.read_text()
# The branch DID reach the remote.
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines()), refs.stdout
# Audit row records the skip.
rows = db._conn.execute(
"SELECT tool, result_json FROM tool_calls WHERE tool='gh_push_branch' ORDER BY id"
).fetchall()
skipped = [json.loads(r["result_json"] or "{}") for r in rows]
assert any(s.get("skipped") == "bun_run_fix" for s in skipped)
assert any(s.get("skipped") == "bun_check" for s in skipped)
def test_gh_push_branch_skip_checks_still_refuses_dirty_worktree(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`skip_checks=true` MUST still refuse when there are uncommitted changes —
we never let uncommitted diff leak into a remote ref."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="dirty",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
# package.json declares scripts.fix so the dirty-tree gate inside
# _run_pre_publish_bun_fix actually runs (the helper short-circuits to
# a no-op when there is no scripts.fix entry).
(ws.repo_dir / "package.json").write_text(
json.dumps({"scripts": {"fix": "ruff format"}}) + "\n",
encoding="utf-8",
)
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "package.json"], check=True, capture_output=True)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"wip",
],
check=True,
capture_output=True,
env=env,
)
# Now leave an uncommitted edit on disk.
(ws.repo_dir / "dirty.txt").write_text("uncommitted\n")
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RpcCommandError) as exc:
tool.execute({"skip_checks": True}, _ctx())
finally:
_stop_loop(loop, thread)
msg = str(exc.value)
assert "dirty worktree" in msg
# Branch did NOT reach the remote.
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert not any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines()), refs.stdout
def test_gh_open_pr_runs_fix_then_check_and_amends_formatter_diff(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""gh_open_pr runs `bun run fix`, amends any diff into HEAD, then runs `bun check`."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="fix runs before check",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
# The fake bun:
# `bun run fix` → rewrite src.txt and emit a small marker the test asserts on
# `bun check` → exit 0
# anything else → fail
fix_calls = tmp_path / "fix-calls"
check_calls = tmp_path / "check-calls"
fakebin = tmp_path / "fakebin"
fakebin.mkdir()
fake_bun = fakebin / "bun"
fake_bun.write_text(
"#!/bin/sh\n"
'if [ "$1" = "run" ] && [ "$2" = "fix" ]; then\n'
f" printf called >> {fix_calls}\n"
' printf "formatted\\n" > src.txt\n'
" exit 0\n"
"fi\n"
'if [ "$1" = "check" ]; then\n'
f" printf called >> {check_calls}\n"
" exit 0\n"
"fi\n"
'printf "unexpected bun call: %s\\n" "$*" >&2\n'
"exit 2\n"
)
fake_bun.chmod(0o755)
monkeypatch.setenv("PATH", f"{fakebin}{os.pathsep}{os.environ['PATH']}")
(ws.repo_dir / "package.json").write_text(
json.dumps({"scripts": {"fix": "...", "check": "..."}}) + "\n",
encoding="utf-8",
)
(ws.repo_dir / "src.txt").write_text("original\n")
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "package.json", "src.txt"], check=True, capture_output=True)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"feat: initial change",
],
check=True,
capture_output=True,
env=env,
)
opened_pr: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
opened_pr["url"] = str(request.url)
return httpx.Response(
201,
json={
"number": 7,
"html_url": "https://github.com/octo/widget/pull/7",
"head": {"ref": ws.branch},
"base": {"ref": "main"},
"state": "open",
},
)
github = GitHubClient("tok", transport=httpx.MockTransport(handler))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n\nFixes #42\n"
result = tool.execute({"title": "fix: x", "body": body}, _ctx())
finally:
_stop_loop(loop, thread)
# Both bun stages ran, and fix preceded check.
assert fix_calls.read_text() == "called"
assert check_calls.read_text() == "called"
# The formatter diff was amended into HEAD — subject and author retained,
# no standalone "style:" commit, and HEAD's tree holds the formatted file.
log = subprocess.run(
["git", "-C", str(ws.repo_dir), "log", "--format=%an|%ae|%s", "-2"],
capture_output=True,
text=True,
check=True,
)
lines = log.stdout.strip().splitlines()
assert lines[0] == "robomp-bot|robomp-bot@example.invalid|feat: initial change"
assert lines[1].endswith("|init")
show = subprocess.run(
["git", "-C", str(ws.repo_dir), "show", "HEAD:src.txt"],
capture_output=True,
text=True,
check=True,
)
assert show.stdout == "formatted\n"
# Worktree is clean again (gate before push would have rejected otherwise).
status = subprocess.run(
["git", "-C", str(ws.repo_dir), "status", "--porcelain"],
capture_output=True,
text=True,
check=True,
)
assert status.stdout == ""
# The PR actually opened.
assert "opened #7" in result
assert opened_pr["url"].endswith("/repos/octo/widget/pulls")
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert f"refs/heads/{ws.branch}" in refs.stdout.splitlines()
def test_gh_open_pr_refuses_dirty_worktree_before_fix(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A pre-existing uncommitted edit MUST cause gh_open_pr (and gh_push_branch)
to refuse BEFORE `bun run fix` runs — otherwise `git add -A` after fix
would silently amend the unrelated edit into the agent's HEAD commit
and ship it in the PR."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="dirty before fix",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
# `bun run fix` is a no-op; `bun check` would also pass. The bug being
# tested is the staging order, not the formatter's behavior.
fakebin = tmp_path / "fakebin"
fakebin.mkdir()
fake_bun = fakebin / "bun"
fake_bun.write_text("#!/bin/sh\nexit 0\n")
fake_bun.chmod(0o755)
monkeypatch.setenv("PATH", f"{fakebin}{os.pathsep}{os.environ['PATH']}")
# Commit a clean package.json + tracked source; then leave an UNRELATED
# uncommitted edit sitting in the worktree (the kind of thing the agent
# forgot to commit).
(ws.repo_dir / "package.json").write_text(
json.dumps({"scripts": {"fix": "...", "check": "..."}}) + "\n",
encoding="utf-8",
)
(ws.repo_dir / "src.txt").write_text("clean\n")
subprocess.run(
["git", "-C", str(ws.repo_dir), "add", "package.json", "src.txt"],
check=True,
capture_output=True,
)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"feat: committed work",
],
check=True,
capture_output=True,
env=env,
)
head_before = subprocess.run(
["git", "-C", str(ws.repo_dir), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
# Now plant the stowaway edit that the agent forgot.
(ws.repo_dir / "src.txt").write_text("STOWAWAY uncommitted edit\n")
github = GitHubClient("tok", transport=httpx.MockTransport(lambda r: httpx.Response(500)))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
push_tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
with pytest.raises(RpcCommandError) as exc:
push_tool.execute({}, _ctx())
assert "dirty worktree" in str(exc.value).lower()
pr_tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
body = "## Repro\nr\n\n## Cause\nc\n\n## Fix\nf\n\n## Verification\nv\n\nFixes #42\n"
with pytest.raises(RpcCommandError) as exc2:
pr_tool.execute({"title": "fix: x", "body": body}, _ctx())
assert "dirty worktree" in str(exc2.value).lower()
finally:
_stop_loop(loop, thread)
# HEAD is unchanged: the unrelated edit was NEVER committed.
head_after = subprocess.run(
["git", "-C", str(ws.repo_dir), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert head_after == head_before
# The stowaway edit is still sitting uncommitted in the worktree.
status = subprocess.run(
["git", "-C", str(ws.repo_dir), "status", "--porcelain"],
capture_output=True,
text=True,
check=True,
)
assert "src.txt" in status.stdout
# Origin's farm/* branch was never created — push refused before reaching the network.
refs = subprocess.run(
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
capture_output=True,
text=True,
check=True,
)
assert not any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines())
def test_gh_open_pr_skips_fix_when_no_script(db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""No `scripts.fix` entry → fix stage is a no-op even if `scripts.check` exists."""
import os
import subprocess
bare = tmp_path / "upstream.git"
bare.mkdir()
subprocess.run(["git", "init", "--bare", "--initial-branch=main", str(bare)], check=True, capture_output=True)
seed = tmp_path / "seed"
seed.mkdir()
env = os.environ | {
"GIT_AUTHOR_NAME": "robomp-bot",
"GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
"GIT_COMMITTER_NAME": "robomp-bot",
"GIT_COMMITTER_EMAIL": "robomp-bot@example.invalid",
}
subprocess.run(["git", "init", "--initial-branch=main", str(seed)], check=True, capture_output=True)
(seed / "README.md").write_text("init\n")
for cmd in (
["git", "-C", str(seed), "add", "."],
[
"git",
"-C",
str(seed),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"init",
],
["git", "-C", str(seed), "remote", "add", "origin", str(bare)],
["git", "-C", str(seed), "push", "origin", "main"],
):
subprocess.run(cmd, check=True, capture_output=True, env=env)
from robomp.sandbox import SandboxManager
mgr = SandboxManager(tmp_path / "workspaces")
ws = mgr.ensure_workspace(
repo="octo/widget",
number=42,
title="no fix script",
clone_url=str(bare),
default_branch="main",
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
fix_calls = tmp_path / "fix-calls"
check_calls = tmp_path / "check-calls"
fakebin = tmp_path / "fakebin"
fakebin.mkdir()
fake_bun = fakebin / "bun"
fake_bun.write_text(
"#!/bin/sh\n"
'if [ "$1" = "run" ] && [ "$2" = "fix" ]; then\n'
f" printf called >> {fix_calls}\n"
" exit 0\n"
"fi\n"
'if [ "$1" = "check" ]; then\n'
f" printf called >> {check_calls}\n"
" exit 0\n"
"fi\n"
"exit 2\n"
)
fake_bun.chmod(0o755)
monkeypatch.setenv("PATH", f"{fakebin}{os.pathsep}{os.environ['PATH']}")
(ws.repo_dir / "package.json").write_text(
json.dumps({"scripts": {"check": "..."}}) + "\n",
encoding="utf-8",
)
subprocess.run(["git", "-C", str(ws.repo_dir), "add", "package.json"], check=True, capture_output=True)
subprocess.run(
[
"git",
"-C",
str(ws.repo_dir),
"-c",
"user.email=robomp-bot@example.invalid",
"-c",
"user.name=robomp-bot",
"commit",
"-m",
"feat: x",
],
check=True,
capture_output=True,
env=env,
)
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(
201,
json={
"number": 7,
"html_url": "https://github.com/octo/widget/pull/7",
"head": {"ref": ws.branch},
"base": {"ref": "main"},
"state": "open",
},
)
github = GitHubClient("tok", transport=httpx.MockTransport(handler))
loop, thread = _make_loop_in_background()
try:
bindings = ToolBindings(
db=db,
github=github,
git_transport=LocalGitTransport(token=None),
repo=_stub_repo(),
issue=IssueInfo(
repo="octo/widget",
number=42,
title="t",
body="",
state="open",
author="alice",
labels=(),
is_pull_request=False,
),
workspace=ws,
loop=loop,
author_name="robomp-bot",
author_email="robomp-bot@example.invalid",
)
db.upsert_issue(
key=bindings.issue_key,
repo="octo/widget",
number=42,
state="reproducing",
branch=ws.branch,
session_dir=str(ws.session_dir),
)
db.set_issue_classification(bindings.issue_key, "bug")
tool = next(x for x in build(bindings) if x.name == "gh_open_pr")
body = "## Repro\nrepro\n\n## Cause\ncause\n\n## Fix\nfix\n\n## Verification\nran tests\n\nFixes #42\n"
result = tool.execute({"title": "fix: x", "body": body}, _ctx())
finally:
_stop_loop(loop, thread)
assert not fix_calls.exists()
assert check_calls.read_text() == "called"
assert "opened #7" in result
# -------- gh_post_comment + question auto-close ---------------------------
def _stub_settings(*, enabled: bool = True, hours: float = 4.0):
"""Construct a Settings stub with question_autoclose knobs only.
`model_construct` skips field validation, which lets us avoid wiring up
every required env var just to set the autoclose fields a test needs.
"""
from pydantic import SecretStr
from robomp.config import Settings
return Settings.model_construct(
github_token=None,
github_webhook_secret=SecretStr("x"),
bot_login="robomp-bot",
git_author_email="bot@example.invalid",
repo_allowlist_raw="octo/widget",
gh_proxy_url="http://proxy.invalid",
gh_proxy_hmac_key=SecretStr("k" * 32),
question_autoclose_enabled=enabled,
question_autoclose_hours=hours,
question_autoclose_scan_seconds=60.0,
)
def _question_handler(captured: dict[str, Any]):
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = json.loads(request.content)
return httpx.Response(
201,
json={"id": 4242, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"},
)
return handler
def test_gh_post_comment_appends_suffix_and_schedules_for_question(db: Database, tmp_path: Path) -> None:
captured: dict[str, Any] = {}
transport = httpx.MockTransport(_question_handler(captured))
bindings, loop, t = _bindings(db, tmp_path, transport)
db.set_issue_classification(bindings.issue_key, "question")
bindings = ToolBindings(
db=bindings.db,
github=bindings.github,
git_transport=bindings.git_transport,
repo=bindings.repo,
issue=bindings.issue,
workspace=bindings.workspace,
loop=bindings.loop,
author_name=bindings.author_name,
author_email=bindings.author_email,
settings=_stub_settings(),
)
try:
tool = next(x for x in build(bindings) if x.name == "gh_post_comment")
tool.execute({"body": "Here's the answer"}, _ctx())
finally:
_stop_loop(loop, t)
body = captured["body"]["body"]
assert body.startswith("Here's the answer")
# Suffix appended exactly once.
assert body.count("react 👎") == 1
assert "auto-close in 4 hours" in body
row = db.get_pending_closure(bindings.issue_key)
assert row is not None
assert row.state == "pending"
assert row.comment_id == 4242
# `_stub_issue()` opens the issue as `alice`.
assert row.issue_author == "alice"
def test_gh_post_comment_skips_suffix_for_non_question(db: Database, tmp_path: Path) -> None:
captured: dict[str, Any] = {}
transport = httpx.MockTransport(_question_handler(captured))
bindings, loop, t = _bindings(db, tmp_path, transport)
db.set_issue_classification(bindings.issue_key, "bug")
bindings = ToolBindings(
db=bindings.db,
github=bindings.github,
git_transport=bindings.git_transport,
repo=bindings.repo,
issue=bindings.issue,
workspace=bindings.workspace,
loop=bindings.loop,
author_name=bindings.author_name,
author_email=bindings.author_email,
settings=_stub_settings(),
)
try:
tool = next(x for x in build(bindings) if x.name == "gh_post_comment")
tool.execute({"body": "Here's the diagnosis"}, _ctx())
finally:
_stop_loop(loop, t)
assert captured["body"] == {"body": "Here's the diagnosis"}
assert db.get_pending_closure(bindings.issue_key) is None
def test_gh_post_comment_skips_suffix_when_target_differs_from_origin(db: Database, tmp_path: Path) -> None:
"""Posting to a different `number` (e.g. cross-issue reply) must not schedule."""
captured: dict[str, Any] = {}
transport = httpx.MockTransport(_question_handler(captured))
bindings, loop, t = _bindings(db, tmp_path, transport)
db.set_issue_classification(bindings.issue_key, "question")
bindings = ToolBindings(
db=bindings.db,
github=bindings.github,
git_transport=bindings.git_transport,
repo=bindings.repo,
issue=bindings.issue,
workspace=bindings.workspace,
loop=bindings.loop,
author_name=bindings.author_name,
author_email=bindings.author_email,
settings=_stub_settings(),
)
try:
tool = next(x for x in build(bindings) if x.name == "gh_post_comment")
tool.execute({"body": "see other issue", "number": 99}, _ctx())
finally:
_stop_loop(loop, t)
assert captured["body"] == {"body": "see other issue"}
assert db.get_pending_closure(bindings.issue_key) is None
def test_gh_post_comment_skips_suffix_when_feature_disabled(db: Database, tmp_path: Path) -> None:
captured: dict[str, Any] = {}
transport = httpx.MockTransport(_question_handler(captured))
bindings, loop, t = _bindings(db, tmp_path, transport)
db.set_issue_classification(bindings.issue_key, "question")
bindings = ToolBindings(
db=bindings.db,
github=bindings.github,
git_transport=bindings.git_transport,
repo=bindings.repo,
issue=bindings.issue,
workspace=bindings.workspace,
loop=bindings.loop,
author_name=bindings.author_name,
author_email=bindings.author_email,
settings=_stub_settings(enabled=False),
)
try:
tool = next(x for x in build(bindings) if x.name == "gh_post_comment")
tool.execute({"body": "Here's the answer"}, _ctx())
finally:
_stop_loop(loop, t)
assert captured["body"] == {"body": "Here's the answer"}
assert db.get_pending_closure(bindings.issue_key) is None