Files
oh-my-pi/tests/test_host_tools.py
T
2026-05-15 01:53:50 +02:00

1774 lines
60 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.db import Database
from robomp.github_client import GitHubClient, IssueInfo, RepoInfo
from robomp.host_tools import 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
) -> 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",
)
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_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)
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 "saved transcript" in result
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_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_and_abandons(db: Database, tmp_path: Path) -> None:
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content)
return httpx.Response(201, json={"id": 77, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
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 "abandonment" in result
assert "Could not reproduce" in captured["body"]["body"]
issue = db.get_issue(bindings.issue_key)
assert issue and issue.state == "abandoned"
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_rejects_priority_on_non_bug(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": "question", "priority": "prio:p1", "rationale": "x"},
_ctx(),
)
finally:
_stop_loop(loop, t)
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 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),
)
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),
)
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),
)
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)))
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))
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),
)
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),
)
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 committed by the bot as a `style: bun run fix` commit.
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].startswith("robomp-bot <robomp-bot@example.invalid> style: bun run fix"), lines
# 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),
)
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),
)
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_open_pr_runs_fix_then_check_and_commits_fixup(
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""gh_open_pr runs `bun run fix`, commits any diff as the bot, 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),
)
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 committed by the bot as a "style:" commit.
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|style: bun run fix"
assert lines[1].endswith("|feat: initial change")
# 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_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),
)
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