3607 lines
128 KiB
Python
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
|