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

2766 lines
93 KiB
Python

"""End-to-end coverage for the FastAPI surface (dashboard + JSON APIs)."""
from __future__ import annotations
import hashlib
import hmac
import json
from pathlib import Path
import httpx
import pytest
from fastapi.testclient import TestClient
from robomp.config import Settings, reset_settings_cache
from robomp.dashboard import tail_jsonl
from robomp.db import Database, close_database, get_database, issue_key
from robomp.github_client import GitHubClient
from robomp.manual_triage import InvalidIssueRef, ManualTriageTimeout, await_terminal_state, parse_issue_ref
from robomp.sandbox import LocalGitTransport
from robomp.server import create_app
def _seed_db(settings: Settings) -> None:
db = get_database(settings.sqlite_path)
db.record_event(
delivery_id="d-queued",
event_type="issues",
repo="octo/widget",
issue_key=issue_key("octo/widget", 1),
payload={"action": "opened", "issue": {"number": 1}},
)
db.record_event(
delivery_id="d-skipped",
event_type="issues",
repo="octo/widget",
issue_key=issue_key("octo/widget", 2),
payload={"action": "labeled"},
state="skipped",
)
# Promote one event to "running" so the running_events list isn't empty.
db.record_event(
delivery_id="d-running",
event_type="issue_comment",
repo="octo/widget",
issue_key=issue_key("octo/widget", 3),
payload={"action": "created"},
)
claimed = db.claim_next_event()
assert claimed is not None # d-queued or d-running depending on order
# Make sure at least one event is in running state for our assertions.
db.upsert_issue(
key=issue_key("octo/widget", 3),
repo="octo/widget",
number=3,
state="opened",
branch="farm/abc12345/fix",
pr_number=42,
)
db.set_issue_classification(issue_key("octo/widget", 3), "bug")
def test_index_serves_dashboard_html(settings: Settings) -> None:
app = create_app(settings)
with TestClient(app) as client:
resp = client.get("/")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
# Stable anchors only. The Vite bundle hashes its asset filenames on every
# build, but the structural skeleton (title, mount node, config script)
# has to stay intact for the SPA to bootstrap.
assert "<title>robomp</title>" in resp.text
assert 'id="app"' in resp.text
assert 'id="robomp-config"' in resp.text
# The sentinel must have been substituted — neither the literal sentinel
# nor an empty script body is acceptable.
assert "__ROBOMP_CONFIG__" not in resp.text
assert '"replayEnabled":' in resp.text
def test_index_substitutes_replay_token(env, monkeypatch: pytest.MonkeyPatch) -> None:
"""When a replay token is set, the config blob exposes it to the SPA."""
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", "secret-token-7")
reset_settings_cache()
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
try:
with TestClient(app) as client:
resp = client.get("/")
assert resp.status_code == 200
assert '"replayEnabled":true' in resp.text
assert '"replayToken":"secret-token-7"' in resp.text
finally:
close_database()
def test_api_status_reports_runtime_counts_and_inflight(settings: Settings) -> None:
app = create_app(settings)
with TestClient(app) as client:
_seed_db(settings)
resp = client.get("/api/status")
close_database()
assert resp.status_code == 200
body = resp.json()
runtime = body["runtime"]
assert runtime["bot_login"] == "robomp-bot"
assert runtime["repo_allowlist"] == ["octo/widget"]
assert runtime["max_concurrency"] == settings.max_concurrency
assert runtime["model"] == settings.model
assert runtime["uptime_seconds"] >= 0
counts = body["event_counts"]
# All five buckets must be present even when zero — the UI relies on it.
assert set(counts) == {"queued", "running", "done", "failed", "skipped"}
assert counts["queued"] + counts["running"] == 2 # d-queued + d-running
assert counts["skipped"] == 1
assert counts["running"] >= 1
running = body["running_events"]
assert running, "expected at least one running event after claim"
assert all(r["started_at"] for r in running)
# No worker pool was started in TestClient lifespan? It actually is — verify
# the inflight snapshot returns a list even when empty.
assert isinstance(body["inflight"], list)
issues = {i["key"]: i for i in body["issues"]}
fix_key = issue_key("octo/widget", 3)
assert fix_key in issues
assert issues[fix_key]["classification"] == "bug"
assert issues[fix_key]["pr_number"] == 42
assert issues[fix_key]["branch"] == "farm/abc12345/fix"
delivery_ids = {e["delivery_id"] for e in body["recent_events"]}
assert {"d-queued", "d-skipped", "d-running"}.issubset(delivery_ids)
def test_api_status_reports_current_issue_event_state(settings: Settings) -> None:
app = create_app(settings)
fixed = issue_key("octo/widget", 44)
failed = issue_key("octo/widget", 69)
with TestClient(app) as client:
db = get_database(settings.sqlite_path)
db.upsert_issue(key=fixed, repo="octo/widget", number=44, state="closed", pr_number=1084)
db.upsert_issue(key=failed, repo="octo/widget", number=69, state="reproducing")
db.record_event(
delivery_id="fixed-old-failure",
event_type="issues",
repo="octo/widget",
issue_key=fixed,
payload={"action": "opened"},
state="failed",
)
db.record_event(
delivery_id="fixed-later-success",
event_type="issues",
repo="octo/widget",
issue_key=fixed,
payload={"action": "closed"},
state="done",
)
db.record_event(
delivery_id="still-failed",
event_type="issues",
repo="octo/widget",
issue_key=failed,
payload={"action": "opened"},
state="failed",
)
db.record_event(
delivery_id="failed-label-noise",
event_type="issues",
repo="octo/widget",
issue_key=failed,
payload={"action": "labeled"},
state="skipped",
last_error="issues.labeled ignored",
)
resp = client.get("/api/status")
close_database()
assert resp.status_code == 200
body = resp.json()
assert body["event_counts"]["failed"] == 2
assert body["issue_event_counts"]["failed"] == 1
assert body["issue_event_counts"]["done"] == 1
assert body["issue_event_counts"]["skipped"] == 0
issues = {i["key"]: i for i in body["issues"]}
assert issues[fixed]["latest_event"]["delivery_id"] == "fixed-later-success"
assert issues[fixed]["latest_event"]["state"] == "done"
assert issues[failed]["latest_event"]["delivery_id"] == "still-failed"
assert issues[failed]["latest_event"]["state"] == "failed"
def test_api_logs_returns_empty_when_file_missing(settings: Settings) -> None:
app = create_app(settings)
with TestClient(app) as client:
resp = client.get("/api/logs?limit=10")
close_database()
assert resp.status_code == 200
body = resp.json()
assert body == {"entries": [], "count": 0, "limit": 10}
def test_api_logs_tails_jsonl_file(settings: Settings) -> None:
log_path = settings.log_dir / "robomp.log.jsonl"
log_path.parent.mkdir(parents=True, exist_ok=True)
payloads = [
{"ts": "2026-05-14T21:28:28Z", "level": "INFO", "logger": "robomp.queue", "msg": "dispatch loop online"},
{
"ts": "2026-05-14T21:28:54Z",
"level": "INFO",
"logger": "robomp.server",
"msg": "skip",
"event": "issues",
"reason": "issues.labeled ignored",
},
{"ts": "2026-05-14T21:30:00Z", "level": "WARNING", "logger": "robomp.queue", "msg": "tool_end", "ok": False},
]
log_path.write_text("\n".join(json.dumps(p) for p in payloads) + "\n", encoding="utf-8")
app = create_app(settings)
with TestClient(app) as client:
resp = client.get("/api/logs?limit=2")
close_database()
assert resp.status_code == 200
body = resp.json()
assert body["count"] == 2
assert body["limit"] == 2
# Oldest of the requested window first.
assert body["entries"][0]["msg"] == "skip"
assert body["entries"][1]["msg"] == "tool_end"
assert body["entries"][1]["level"] == "WARNING"
def test_api_logs_limit_is_clamped(settings: Settings) -> None:
app = create_app(settings)
with TestClient(app) as client:
too_low = client.get("/api/logs?limit=0").json()
too_high = client.get("/api/logs?limit=99999").json()
close_database()
assert too_low["limit"] == 1
assert too_high["limit"] == 2000
def test_tail_jsonl_recovers_from_garbage_lines(tmp_path: Path) -> None:
path = tmp_path / "noisy.jsonl"
path.write_text(
json.dumps({"ts": "a", "level": "INFO", "msg": "ok"}) + "\n"
"{not json}\n" + json.dumps({"ts": "b", "level": "ERROR", "msg": "bang"}) + "\n",
encoding="utf-8",
)
rows = tail_jsonl(path, limit=10)
assert len(rows) == 3
assert rows[0]["msg"] == "ok"
assert rows[1]["level"] == "RAW"
assert rows[1]["msg"] == "{not json}"
assert rows[2]["level"] == "ERROR"
# ---------- manual_triage helpers ----------
def test_parse_issue_ref_accepts_owner_repo_hash_number() -> None:
assert parse_issue_ref("octo/widget#42") == ("octo/widget", 42)
assert parse_issue_ref(" octo/widget#42 ") == ("octo/widget", 42)
def test_parse_issue_ref_accepts_github_issue_urls() -> None:
cases = (
"https://github.com/can1357/oh-my-pi/issues/1348",
"http://github.com/can1357/oh-my-pi/issues/1348",
"github.com/can1357/oh-my-pi/issues/1348",
"https://www.github.com/can1357/oh-my-pi/issues/1348",
"https://github.com/can1357/oh-my-pi/issues/1348/",
"https://github.com/can1357/oh-my-pi/issues/1348?foo=bar",
"https://github.com/can1357/oh-my-pi/issues/1348#issuecomment-99",
" https://github.com/can1357/oh-my-pi/issues/1348 ",
)
for case in cases:
assert parse_issue_ref(case) == ("can1357/oh-my-pi", 1348), case
def test_parse_issue_ref_rejects_garbage() -> None:
for bad in (
"widget#1",
"octo/widget",
"octo/widget#abc",
"octo widget#1",
"",
"https://github.com/octo/widget/pull/1",
"https://github.com/octo/widget/issues/",
"https://gitlab.com/octo/widget/issues/1",
):
with pytest.raises(InvalidIssueRef):
parse_issue_ref(bad)
@pytest.mark.asyncio
async def test_await_terminal_state_times_out_with_current_state(db: Database) -> None:
db.record_event(
delivery_id="d-wait",
event_type="issues",
repo="octo/widget",
issue_key=issue_key("octo/widget", 42),
payload={"action": "opened"},
)
with pytest.raises(ManualTriageTimeout) as excinfo:
await await_terminal_state(db, "d-wait", poll_interval=0.001, timeout=0.001)
assert excinfo.value.delivery_id == "d-wait"
assert excinfo.value.state == "queued"
# ---------- /api/trigger ----------
def _enable_replay(monkeypatch: pytest.MonkeyPatch) -> str:
token = "trigger-secret"
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", token)
reset_settings_cache()
return token
def _install_github_mock(app, transport: httpx.MockTransport) -> None:
"""Replace the real GitHub client with one wired to a MockTransport."""
app.state.bag["github"] = GitHubClient("token", transport=transport)
def test_trigger_returns_404_when_token_disabled(settings: Settings) -> None:
app = create_app(settings)
with TestClient(app) as client:
resp = client.post("/api/trigger", json={"mode": "triage", "issue": "octo/widget#1"})
close_database()
assert resp.status_code == 404
assert "trigger disabled" in resp.json()["detail"]
def test_trigger_rejects_missing_token(env, monkeypatch: pytest.MonkeyPatch) -> None:
_enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
resp = client.post("/api/trigger", json={"mode": "triage", "issue": "octo/widget#1"})
close_database()
assert resp.status_code == 401
def test_trigger_triage_fetches_and_enqueues(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
captured: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request.url.path)
if request.url.path.endswith("/issues/7"):
return httpx.Response(
200,
json={
"number": 7,
"title": "boom",
"body": "details here",
"state": "open",
"user": {"login": "alice"},
"labels": [{"name": "bug"}],
},
)
if request.url.path.endswith("/repos/octo/widget"):
return httpx.Response(
200,
json={
"full_name": "octo/widget",
"default_branch": "main",
"clone_url": "https://github.com/octo/widget.git",
"private": False,
},
)
return httpx.Response(404)
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, httpx.MockTransport(handler))
resp = client.post(
"/api/trigger",
json={"mode": "triage", "issue": "octo/widget#7"},
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 202, resp.text
body = resp.json()
assert body["mode"] == "triage"
assert body["state"] == "queued"
assert body["delivery"] == "manual-octo__widget-7"
# Both endpoints should have been hit on GitHub.
assert any(p.endswith("/issues/7") for p in captured)
assert any(p.endswith("/repos/octo/widget") for p in captured)
@pytest.mark.parametrize("state", ["queued", "running"])
def test_trigger_triage_conflicts_when_manual_delivery_is_active(
env, monkeypatch: pytest.MonkeyPatch, state: str
) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
delivery = "manual-octo__widget-7"
original_payload = {"action": "opened", "issue": {"number": 7, "title": "old"}}
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return httpx.Response(500, json={"message": "should not fetch active manual event"})
app = create_app(cfg)
with TestClient(app) as client:
db = get_database(cfg.sqlite_path)
db.record_event(
delivery_id=delivery,
event_type="issues",
repo="octo/widget",
issue_key=issue_key("octo/widget", 7),
payload=original_payload,
state=state,
)
_install_github_mock(app, httpx.MockTransport(handler))
resp = client.post(
"/api/trigger",
json={"mode": "triage", "issue": "octo/widget#7"},
headers={"X-Robomp-Replay-Token": token},
)
row = get_database(cfg.sqlite_path).get_event(delivery)
close_database()
assert resp.status_code == 409, resp.text
assert row is not None
assert row.state == state
assert row.payload == original_payload
assert calls == []
@pytest.mark.parametrize("state", ["done", "failed", "skipped"])
def test_trigger_triage_replaces_inactive_manual_delivery(env, monkeypatch: pytest.MonkeyPatch, state: str) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
delivery = "manual-octo__widget-7"
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/issues/7"):
return httpx.Response(
200,
json={
"number": 7,
"title": "fresh",
"body": "new details",
"state": "open",
"user": {"login": "alice"},
"labels": [{"name": "bug"}],
},
)
if request.url.path.endswith("/repos/octo/widget"):
return httpx.Response(
200,
json={
"full_name": "octo/widget",
"default_branch": "main",
"clone_url": "https://github.com/octo/widget.git",
"private": False,
},
)
return httpx.Response(404)
app = create_app(cfg)
with TestClient(app) as client:
db = get_database(cfg.sqlite_path)
db.record_event(
delivery_id="running-octo__widget-7",
event_type="issue_comment",
repo="octo/widget",
issue_key=issue_key("octo/widget", 7),
payload={"action": "created"},
)
claimed = db.claim_next_event()
assert claimed is not None
assert claimed.delivery_id == "running-octo__widget-7"
db.record_event(
delivery_id=delivery,
event_type="issues",
repo="octo/widget",
issue_key=issue_key("octo/widget", 7),
payload={"action": "opened", "issue": {"number": 7, "title": "old"}},
state=state,
)
_install_github_mock(app, httpx.MockTransport(handler))
resp = client.post(
"/api/trigger",
json={"mode": "triage", "issue": "octo/widget#7"},
headers={"X-Robomp-Replay-Token": token},
)
row = get_database(cfg.sqlite_path).get_event(delivery)
close_database()
assert resp.status_code == 202, resp.text
assert row is not None
assert row.state == "queued"
assert row.attempts == 0
assert row.payload["issue"]["title"] == "fresh"
def test_trigger_triage_rejects_pull_request_issue_payload(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
captured: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request.url.path)
if request.url.path.endswith("/issues/7"):
return httpx.Response(
200,
json={
"number": 7,
"title": "change",
"body": "details here",
"state": "open",
"user": {"login": "alice"},
"labels": [{"name": "bug"}],
"pull_request": {"url": "https://api.github.com/repos/octo/widget/pulls/7"},
},
)
if request.url.path.endswith("/repos/octo/widget"):
return httpx.Response(
200,
json={
"full_name": "octo/widget",
"default_branch": "main",
"clone_url": "https://github.com/octo/widget.git",
"private": False,
},
)
return httpx.Response(404)
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, httpx.MockTransport(handler))
resp = client.post(
"/api/trigger",
json={"mode": "triage", "issue": "octo/widget#7"},
headers={"X-Robomp-Replay-Token": token},
)
assert get_database(cfg.sqlite_path).get_event("manual-octo__widget-7") is None
close_database()
assert resp.status_code == 400, resp.text
assert "pull request" in resp.json()["detail"]
assert any(p.endswith("/issues/7") for p in captured)
assert not any(p.endswith("/repos/octo/widget") for p in captured)
def test_trigger_triage_rejects_repo_not_in_allowlist(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, httpx.MockTransport(lambda r: httpx.Response(500)))
resp = client.post(
"/api/trigger",
json={"mode": "triage", "issue": "evil/repo#1"},
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 403
assert "ROBOMP_REPO_ALLOWLIST" in resp.json()["detail"]
@pytest.mark.parametrize("state", ["queued", "running"])
def test_trigger_retry_by_delivery_rejects_active_events(
env,
monkeypatch: pytest.MonkeyPatch,
state: str,
) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
db = get_database(cfg.sqlite_path)
db.record_event(
delivery_id=f"d-{state}",
event_type="issues",
repo="octo/widget",
issue_key=issue_key("octo/widget", 5),
payload={"action": "opened", "issue": {"number": 5}},
state=state,
)
resp = client.post(
"/api/trigger",
json={"mode": "retry", "delivery_id": f"d-{state}"},
headers={"X-Robomp-Replay-Token": token},
)
assert resp.status_code == 409
assert state in resp.json()["detail"]
assert get_database(cfg.sqlite_path).get_event(f"d-{state}").state == state
close_database()
def test_trigger_triage_surfaces_github_failure(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
transport = httpx.MockTransport(lambda r: httpx.Response(404, json={"message": "Not Found"}))
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, transport)
resp = client.post(
"/api/trigger",
json={"mode": "triage", "issue": "octo/widget#999"},
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 502
assert "github error" in resp.json()["detail"]
def test_trigger_retry_by_delivery_id_requeues(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
db = get_database(cfg.sqlite_path)
db.record_event(
delivery_id="running-widget-4",
event_type="issue_comment",
repo="octo/widget",
issue_key=issue_key("octo/widget", 4),
payload={"action": "created"},
)
claimed = db.claim_next_event()
assert claimed is not None
assert claimed.delivery_id == "running-widget-4"
db.record_event(
delivery_id="d-old",
event_type="issues",
repo="octo/widget",
issue_key=issue_key("octo/widget", 4),
payload={"action": "opened", "issue": {"number": 4}},
state="failed",
)
resp = client.post(
"/api/trigger",
json={"mode": "retry", "delivery_id": "d-old"},
headers={"X-Robomp-Replay-Token": token},
)
assert resp.status_code == 202
assert get_database(cfg.sqlite_path).get_event("d-old").state == "queued"
close_database()
def test_trigger_retry_by_issue_finds_latest_non_skipped_event(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
db = get_database(cfg.sqlite_path)
key = issue_key("octo/widget", 9)
db.record_event(
delivery_id="running-widget-9",
event_type="issue_comment",
repo="octo/widget",
issue_key=key,
payload={"action": "created"},
)
claimed = db.claim_next_event()
assert claimed is not None
assert claimed.delivery_id == "running-widget-9"
db.record_event(
delivery_id="d-old-1",
event_type="issues",
repo="octo/widget",
issue_key=key,
payload={"a": 1},
state="failed",
)
db.record_event(
delivery_id="d-old-2",
event_type="issue_comment",
repo="octo/widget",
issue_key=key,
payload={"a": 2},
state="done",
)
db.record_event(
delivery_id="d-label-noise",
event_type="issues",
repo="octo/widget",
issue_key=key,
payload={"a": 3},
state="skipped",
)
resp = client.post(
"/api/trigger",
json={"mode": "retry", "issue": "octo/widget#9"},
headers={"X-Robomp-Replay-Token": token},
)
body = resp.json()
assert resp.status_code == 202, body
# Most recently-received non-skipped row wins; ignored label events do not hide it.
assert body["delivery"] == "d-old-2"
retried = get_database(cfg.sqlite_path).get_event("d-old-2")
assert retried is not None
assert retried.state == "queued"
close_database()
def test_trigger_retry_by_issue_rejects_active_latest_event(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
db = get_database(cfg.sqlite_path)
key = issue_key("octo/widget", 10)
db.record_event(
delivery_id="d-inactive",
event_type="issues",
repo="octo/widget",
issue_key=key,
payload={"a": 1},
state="failed",
)
db.record_event(
delivery_id="d-active",
event_type="issue_comment",
repo="octo/widget",
issue_key=key,
payload={"a": 2},
state="running",
)
resp = client.post(
"/api/trigger",
json={"mode": "retry", "issue": "octo/widget#10"},
headers={"X-Robomp-Replay-Token": token},
)
assert resp.status_code == 409
assert "running" in resp.json()["detail"]
assert get_database(cfg.sqlite_path).get_event("d-active").state == "running"
assert get_database(cfg.sqlite_path).get_event("d-inactive").state == "failed"
close_database()
def test_trigger_retry_by_issue_rejects_repo_not_in_allowlist(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
db = get_database(cfg.sqlite_path)
db.record_event(
delivery_id="d-evil",
event_type="issues",
repo="evil/repo",
issue_key=issue_key("evil/repo", 1),
payload={"a": 1},
state="failed",
)
resp = client.post(
"/api/trigger",
json={"mode": "retry", "issue": "evil/repo#1"},
headers={"X-Robomp-Replay-Token": token},
)
assert resp.status_code == 403
assert "ROBOMP_REPO_ALLOWLIST" in resp.json()["detail"]
assert get_database(cfg.sqlite_path).get_event("d-evil").state == "failed"
close_database()
def test_trigger_retry_unknown_delivery_404s(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
resp = client.post(
"/api/trigger",
json={"mode": "retry", "delivery_id": "nope"},
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 404
def test_trigger_rejects_bad_mode(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
resp = client.post(
"/api/trigger",
json={"mode": "explode"},
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 400
# -------- /webhook/github rate-limiting --------------------------------
def _signed_headers(secret: str, body: bytes, *, event: str, delivery: str) -> dict[str, str]:
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return {
"X-GitHub-Event": event,
"X-GitHub-Delivery": delivery,
"X-Hub-Signature-256": f"sha256={sig}",
"Content-Type": "application/json",
}
def _post_issue_opened(
client: TestClient,
*,
delivery: str,
user: str,
number: int,
association: str = "NONE",
secret: str = "test-webhook-secret",
):
payload = {
"action": "opened",
"issue": {
"number": number,
"user": {"login": user},
"author_association": association,
},
"repository": {"full_name": "octo/widget"},
}
body = json.dumps(payload).encode()
return client.post(
"/webhook/github",
content=body,
headers=_signed_headers(secret, body, event="issues", delivery=delivery),
)
def _post_pr_issue_comment(
client: TestClient,
*,
delivery: str,
user: str,
pr_number: int,
association: str = "NONE",
secret: str = "test-webhook-secret",
):
payload = {
"action": "created",
"comment": {
"user": {"login": user},
"author_association": association,
"body": "follow-up",
},
"issue": {
"number": pr_number,
"pull_request": {"url": f"https://api.github.com/repos/octo/widget/pulls/{pr_number}"},
},
"repository": {"full_name": "octo/widget"},
}
body = json.dumps(payload).encode()
return client.post(
"/webhook/github",
content=body,
headers=_signed_headers(secret, body, event="issue_comment", delivery=delivery),
)
@pytest.fixture
def rate_limited_settings(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> Settings:
monkeypatch.setenv("ROBOMP_RATE_LIMIT_DEFAULT", "2")
monkeypatch.setenv("ROBOMP_RATE_LIMIT_CONTRIBUTOR", "4")
monkeypatch.setenv("ROBOMP_RATE_LIMIT_WINDOW_SECONDS", "3600")
monkeypatch.setenv("ROBOMP_RATE_LIMIT_UNLIMITED", "can1357")
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
return cfg
def test_webhook_rate_limits_unknown_submitter_at_default_cap(rate_limited_settings: Settings) -> None:
app = create_app(rate_limited_settings)
with TestClient(app) as client:
# Default cap is 2 → first two queued, third throttled.
states = []
for i in range(3):
resp = _post_issue_opened(
client,
delivery=f"d-{i}",
user="stranger",
number=100 + i,
association="NONE",
)
assert resp.status_code == 202
states.append(resp.json()["state"])
close_database()
assert states == ["queued", "queued", "skipped"]
def test_webhook_incoming_pr_comment_without_directive_skips_without_counting_budget(
rate_limited_settings: Settings,
) -> None:
app = create_app(rate_limited_settings)
with TestClient(app) as client:
skipped = _post_pr_issue_comment(
client,
delivery="pr-unmapped",
user="stranger",
pr_number=900,
association="NONE",
)
assert skipped.status_code == 202
assert skipped.json()["state"] == "skipped"
states = []
for i in range(3):
resp = _post_issue_opened(
client,
delivery=f"real-{i}",
user="stranger",
number=100 + i,
association="NONE",
)
assert resp.status_code == 202
states.append(resp.json()["state"])
db = get_database(rate_limited_settings.sqlite_path)
unmapped = db.get_event("pr-unmapped")
close_database()
assert unmapped is not None
assert unmapped.issue_key == "octo/widget#900"
assert "incoming PR comments ignored" in (unmapped.last_error or "")
assert states == ["queued", "queued", "skipped"]
def test_webhook_contributor_gets_higher_cap(rate_limited_settings: Settings) -> None:
app = create_app(rate_limited_settings)
with TestClient(app) as client:
# Default cap (2) would block at i=2; CONTRIBUTOR cap (4) allows it.
for i in range(4):
resp = _post_issue_opened(
client,
delivery=f"c-{i}",
user="bob",
number=200 + i,
association="CONTRIBUTOR",
)
assert resp.status_code == 202
assert resp.json()["state"] == "queued", i
resp = _post_issue_opened(
client,
delivery="c-x",
user="bob",
number=299,
association="CONTRIBUTOR",
)
assert resp.json()["state"] == "skipped"
close_database()
def test_webhook_owner_association_bypasses_limit(rate_limited_settings: Settings) -> None:
app = create_app(rate_limited_settings)
with TestClient(app) as client:
for i in range(5): # well over default cap
resp = _post_issue_opened(
client,
delivery=f"o-{i}",
user="acme-staff",
number=300 + i,
association="OWNER",
)
assert resp.json()["state"] == "queued", i
close_database()
def test_webhook_unlimited_allowlist_bypasses_limit(rate_limited_settings: Settings) -> None:
app = create_app(rate_limited_settings)
with TestClient(app) as client:
# NONE association would normally cap at 2, but `can1357` is whitelisted.
for i in range(5):
resp = _post_issue_opened(
client,
delivery=f"u-{i}",
user="can1357",
number=400 + i,
association="NONE",
)
assert resp.json()["state"] == "queued", i
close_database()
def test_webhook_rate_limit_per_user_is_independent(rate_limited_settings: Settings) -> None:
"""One user's cap doesn't drain another user's budget."""
app = create_app(rate_limited_settings)
with TestClient(app) as client:
# alice exhausts default cap.
for i in range(2):
assert (
_post_issue_opened(
client,
delivery=f"a-{i}",
user="alice",
number=500 + i,
association="NONE",
).json()["state"]
== "queued"
)
# alice's next attempt is skipped.
assert (
_post_issue_opened(
client,
delivery="a-x",
user="alice",
number=599,
association="NONE",
).json()["state"]
== "skipped"
)
# bob is untouched.
for i in range(2):
assert (
_post_issue_opened(
client,
delivery=f"b-{i}",
user="bob",
number=600 + i,
association="NONE",
).json()["state"]
== "queued"
)
close_database()
def test_webhook_rate_limited_event_records_reason(rate_limited_settings: Settings) -> None:
"""Throttled events must surface a useful reason on the dashboard feed."""
app = create_app(rate_limited_settings)
with TestClient(app) as client:
for i in range(3):
_post_issue_opened(
client,
delivery=f"r-{i}",
user="charlie",
number=700 + i,
association="NONE",
)
db = get_database(rate_limited_settings.sqlite_path)
skipped = db.get_event("r-2")
close_database()
assert skipped is not None
assert skipped.state == "skipped"
assert skipped.last_error is not None
assert "rate limit" in skipped.last_error
assert "@charlie" in skipped.last_error
# ---------- /api/github/issues ----------
def _allowlist(monkeypatch: pytest.MonkeyPatch, repos: str) -> None:
monkeypatch.setenv("ROBOMP_REPO_ALLOWLIST", repos)
reset_settings_cache()
def _issue_payload(
number: int,
title: str,
*,
state: str = "open",
author: str = "alice",
labels: list[dict] | None = None,
comments: int = 0,
updated_at: str = "2026-05-14T10:00:00Z",
created_at: str = "2026-05-01T10:00:00Z",
repo: str = "octo/widget",
) -> dict:
return {
"number": number,
"title": title,
"state": state,
"user": {"login": author},
"labels": labels or [],
"comments": comments,
"updated_at": updated_at,
"created_at": created_at,
"html_url": f"https://github.com/{repo}/issues/{number}",
}
def _make_issues_handler(
by_repo: dict[str, list[dict]],
*,
expected_state: str = "open",
expected_limit: int = 30,
failing_repos: tuple[str, ...] = (),
) -> httpx.MockTransport:
expected_params = {
"state": expected_state,
"per_page": str(expected_limit),
"sort": "updated",
"direction": "desc",
}
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
params = request.url.params
assert set(params.keys()) == set(expected_params)
for key, expected in expected_params.items():
assert params.get(key) == expected
path = request.url.path
for repo, items in by_repo.items():
if path == f"/repos/{repo}/issues":
return httpx.Response(200, json=items)
for repo in failing_repos:
if path == f"/repos/{repo}/issues":
return httpx.Response(500, json={"message": "boom"})
return httpx.Response(404, json={"message": "not found"})
return httpx.MockTransport(handler)
def test_browse_returns_404_without_token(settings: Settings) -> None:
app = create_app(settings)
with TestClient(app) as client:
resp = client.get("/api/github/issues")
close_database()
assert resp.status_code == 404
def test_browse_returns_401_with_replay_enabled_without_valid_token(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
missing = client.get("/api/github/issues")
wrong = client.get(
"/api/github/issues",
headers={"X-Robomp-Replay-Token": f"{token}-wrong"},
)
close_database()
assert missing.status_code == 401
assert wrong.status_code == 401
def test_browse_fans_out_across_allowlist_and_filters_prs(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
_allowlist(monkeypatch, "octo/widget,octo/gadget")
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
transport = _make_issues_handler(
{
"octo/widget": [
{
"number": 7,
"title": "newest",
"state": "open",
"user": {"login": "alice"},
"labels": [{"name": "bug"}],
"comments": 3,
"updated_at": "2026-05-14T10:00:00Z",
"created_at": "2026-05-01T10:00:00Z",
"html_url": "https://github.com/octo/widget/issues/7",
},
{
"number": 8,
"title": "a PR not an issue",
"state": "open",
"user": {"login": "bob"},
"labels": [],
"comments": 0,
"updated_at": "2026-05-14T11:00:00Z",
"created_at": "2026-05-14T11:00:00Z",
"html_url": "https://github.com/octo/widget/pull/8",
"pull_request": {"url": "..."},
}, # GitHub /issues returns these too
],
"octo/gadget": [
{
"number": 2,
"title": "older",
"state": "open",
"user": {"login": "carol"},
"labels": [],
"comments": 1,
"updated_at": "2026-05-12T09:00:00Z",
"created_at": "2026-05-12T09:00:00Z",
"html_url": "https://github.com/octo/gadget/issues/2",
},
],
},
expected_limit=20,
)
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, transport)
resp = client.get(
"/api/github/issues?state=open&limit=20",
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["repos"] == ["octo/gadget", "octo/widget"]
assert body["errors"] == []
# PR row dropped; issues sorted newest-updated first.
titles = [(i["repo"], i["number"]) for i in body["issues"]]
assert titles == [("octo/widget", 7), ("octo/gadget", 2)]
first = body["issues"][0]
assert first["author"] == "alice"
assert first["labels"] == ["bug"]
assert first["comments"] == 3
assert first["html_url"].endswith("/issues/7")
def test_browse_reuses_cache_until_forced_refresh(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
calls = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
assert request.method == "GET"
assert request.url.path == "/repos/octo/widget/issues"
calls += 1
title = "initial" if calls == 1 else "refreshed"
updated_at = "2026-05-14T10:00:00Z" if calls == 1 else "2026-05-14T11:00:00Z"
return httpx.Response(200, json=[_issue_payload(1, title, updated_at=updated_at)])
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, httpx.MockTransport(handler))
first = client.get(
"/api/github/issues?state=open&limit=20",
headers={"X-Robomp-Replay-Token": token},
)
second = client.get(
"/api/github/issues?state=open&limit=20",
headers={"X-Robomp-Replay-Token": token},
)
forced = client.get(
"/api/github/issues?state=open&limit=20&refresh=1",
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert first.status_code == 200
assert second.status_code == 200
assert forced.status_code == 200
assert calls == 2
assert first.json()["cache"]["hit"] is False
assert first.json()["issues"][0]["title"] == "initial"
assert second.json()["cache"]["hit"] is True
assert second.json()["issues"][0]["title"] == "initial"
assert forced.json()["cache"]["hit"] is False
assert forced.json()["issues"][0]["title"] == "refreshed"
def test_browse_cache_updates_from_issue_webhook(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
calls = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
assert request.url.path == "/repos/octo/widget/issues"
calls += 1
return httpx.Response(200, json=[_issue_payload(4, "before", comments=1)])
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, httpx.MockTransport(handler))
first = client.get(
"/api/github/issues?state=open&limit=20",
headers={"X-Robomp-Replay-Token": token},
)
assert first.status_code == 200
edited = {
"action": "edited",
"issue": _issue_payload(
4,
"after",
labels=[{"name": "bug"}],
comments=3,
updated_at="2026-05-14T12:00:00Z",
),
"repository": {"full_name": "octo/widget"},
}
raw = json.dumps(edited).encode()
webhook_resp = client.post(
"/webhook/github",
content=raw,
headers=_signed_headers("test-webhook-secret", raw, event="issues", delivery="cache-edit"),
)
after = client.get(
"/api/github/issues?state=open&limit=20",
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert webhook_resp.status_code == 202
assert calls == 1
body = after.json()
assert body["cache"]["hit"] is True
assert body["issues"][0]["title"] == "after"
assert body["issues"][0]["comments"] == 3
assert body["issues"][0]["labels"] == ["bug"]
def test_browse_per_repo_failure_does_not_take_down_panel(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
_allowlist(monkeypatch, "octo/widget,octo/dead")
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
transport = _make_issues_handler(
{
"octo/widget": [
{
"number": 1,
"title": "ok",
"state": "open",
"user": {"login": "u"},
"labels": [],
"comments": 0,
"updated_at": "2026-05-14T00:00:00Z",
"created_at": "2026-05-14T00:00:00Z",
"html_url": "https://github.com/octo/widget/issues/1",
},
],
},
failing_repos=("octo/dead",),
)
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, transport)
resp = client.get(
"/api/github/issues",
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 200
body = resp.json()
assert len(body["issues"]) == 1
assert body["issues"][0]["repo"] == "octo/widget"
assert len(body["errors"]) == 1
assert body["errors"][0]["repo"] == "octo/dead"
def test_browse_rejects_bad_state(env, monkeypatch: pytest.MonkeyPatch) -> None:
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, httpx.MockTransport(lambda r: httpx.Response(500)))
resp = client.get(
"/api/github/issues?state=garbage",
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 400
def test_browse_marks_processed_issues_present_in_db(env, monkeypatch: pytest.MonkeyPatch) -> None:
"""The /api/github/issues payload tags every issue with `processed`,
derived live from the `issues` table so freshly-triaged work disappears
from the "fresh issues" filter without invalidating the GitHub cache."""
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
# Seed: issue #7 was previously picked up; #8 is brand new.
db = get_database(cfg.sqlite_path)
db.upsert_issue(
key=issue_key("octo/widget", 7),
repo="octo/widget",
number=7,
state="opened",
)
transport = _make_issues_handler(
{
"octo/widget": [
_issue_payload(7, "already triaged", updated_at="2026-05-14T10:00:00Z"),
_issue_payload(8, "fresh", updated_at="2026-05-14T11:00:00Z"),
],
},
expected_limit=20,
)
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, transport)
resp = client.get(
"/api/github/issues?state=open&limit=20",
headers={"X-Robomp-Replay-Token": token},
)
close_database()
assert resp.status_code == 200, resp.text
by_number = {i["number"]: i for i in resp.json()["issues"]}
assert by_number[7]["processed"] is True
assert by_number[8]["processed"] is False
def test_browse_processed_flag_is_recomputed_on_cache_hit(env, monkeypatch: pytest.MonkeyPatch) -> None:
"""Even when the GitHub cache is reused, `processed` reflects the current DB
so a triage that lands between two dashboard polls hides the entry on the
next refresh without forcing a GitHub round-trip."""
token = _enable_replay(monkeypatch)
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
calls = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(200, json=[_issue_payload(9, "fresh")])
app = create_app(cfg)
with TestClient(app) as client:
_install_github_mock(app, httpx.MockTransport(handler))
first = client.get(
"/api/github/issues?state=open&limit=20",
headers={"X-Robomp-Replay-Token": token},
)
assert first.status_code == 200
assert first.json()["issues"][0]["processed"] is False
# Simulate a triage landing between two dashboard polls.
get_database(cfg.sqlite_path).upsert_issue(
key=issue_key("octo/widget", 9),
repo="octo/widget",
number=9,
state="reproducing",
)
second = client.get(
"/api/github/issues?state=open&limit=20",
headers={"X-Robomp-Replay-Token": token},
)
close_database()
# GitHub was hit only once; the cached entry served the second call.
assert calls == 1
body = second.json()
assert body["cache"]["hit"] is True
assert body["issues"][0]["processed"] is True
# -------- maintainer directives ----------------------------------------
def _post_issue_comment(
client: TestClient,
*,
delivery: str,
user: str,
number: int,
body: str,
association: str = "NONE",
secret: str = "test-webhook-secret",
):
payload = {
"action": "created",
"comment": {
"user": {"login": user},
"author_association": association,
"body": body,
},
"issue": {"number": number},
"repository": {"full_name": "octo/widget"},
}
raw = json.dumps(payload).encode()
return client.post(
"/webhook/github",
content=raw,
headers=_signed_headers(secret, raw, event="issue_comment", delivery=delivery),
)
def test_webhook_directive_on_unknown_issue_is_queued_with_metadata(env) -> None:
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
resp = _post_issue_comment(
client,
delivery="dir-1",
user="can1357",
number=77,
body="@robomp-bot please refactor X",
association="OWNER",
)
assert resp.status_code == 202
assert resp.json()["state"] == "queued"
row = get_database(cfg.sqlite_path).get_event("dir-1")
close_database()
assert row is not None
assert row.state == "queued"
directive = row.payload.get("_robomp_directive")
assert directive == {"body": "please refactor X", "author": "can1357", "pragmas": [], "authorizes_impl": True}
def test_webhook_directive_authorizes_deployed_app_login_without_author_association(
monkeypatch: pytest.MonkeyPatch, env
) -> None:
monkeypatch.setenv("ROBOMP_BOT_LOGIN", "@roboomp[bot]")
monkeypatch.setenv("ROBOMP_REPO_ALLOWLIST", "can1357/widget")
reset_settings_cache()
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
payload = {
"action": "created",
"comment": {
"user": {"login": "can1357"},
"body": "@roboomp go ahead",
},
"issue": {"number": 3196},
"repository": {
"full_name": "can1357/widget",
"owner": {"login": "can1357", "type": "User"},
},
}
raw = json.dumps(payload).encode()
with TestClient(app) as client:
resp = client.post(
"/webhook/github",
content=raw,
headers=_signed_headers("test-webhook-secret", raw, event="issue_comment", delivery="dir-app-login"),
)
assert resp.status_code == 202
assert resp.json()["state"] == "queued"
row = get_database(cfg.sqlite_path).get_event("dir-app-login")
close_database()
assert row is not None
directive = row.payload.get("_robomp_directive")
assert directive == {"body": "go ahead", "author": "can1357", "pragmas": [], "authorizes_impl": True}
def test_webhook_maintainer_bypasses_rate_limit(
rate_limited_settings: Settings,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Login in ROBOMP_MAINTAINER_LOGINS is always unlimited, even with NONE association."""
monkeypatch.setenv("ROBOMP_MAINTAINER_LOGINS", "can1357")
reset_settings_cache()
cfg = Settings() # type: ignore[call-arg]
cfg.ensure_paths()
app = create_app(cfg)
with TestClient(app) as client:
states = []
# cap=2 from rate_limited_settings — 3rd would normally be skipped.
for i in range(4):
resp = _post_issue_comment(
client,
delivery=f"m-{i}",
user="can1357",
number=300 + i,
body=("@robomp-bot do X" if i == 3 else "comment"),
association="NONE",
)
assert resp.status_code == 202
states.append(resp.json()["state"])
directive_event = get_database(cfg.sqlite_path).get_event("m-3")
close_database()
assert states == ["queued"] * 4, states
assert directive_event is not None
assert directive_event.payload.get("_robomp_directive") == {
"body": "do X",
"author": "can1357",
"pragmas": [],
"authorizes_impl": True,
}
# -------- handler-level: bootstrap + reopen ----------------------------
class _RecordingSandbox:
"""Stand-in for SandboxManager: records calls, hands back a fake Workspace."""
natives_cache = None
def __init__(self, tmp_root: Path) -> None:
self.tmp_root = tmp_root
self.ensure_calls: list[dict] = []
self.remove_calls: list[tuple[str, int]] = []
def ensure_workspace(
self,
*,
repo: str,
number: int,
title: str,
clone_url: str,
default_branch: str,
existing_branch=None,
pr_head: int | None = None,
author_name: str = "",
author_email: str = "",
slot_uid: int | None = None,
):
self.ensure_calls.append(
{
"repo": repo,
"number": number,
"title": title,
"default_branch": default_branch,
"existing_branch": existing_branch,
"pr_head": pr_head,
"slot_uid": slot_uid,
}
)
# Mimic Workspace shape — only the attributes ensure_workspace's
# downstream callers touch.
from dataclasses import dataclass
@dataclass(slots=True, frozen=True)
class _W:
branch: str
session_dir: Path
context_dir: Path
repo_dir: Path
wid = f"{repo.replace('/', '__')}__{number}"
return _W(
branch=existing_branch or (f"review/pr-{pr_head}" if pr_head is not None else f"farm/auto/{wid}"),
session_dir=self.tmp_root / wid / "session",
context_dir=self.tmp_root / wid / "context",
repo_dir=self.tmp_root / wid / "repo",
)
def remove_workspace(self, *, repo: str, number: int) -> None:
self.remove_calls.append((repo, number))
@pytest.fixture
def stub_run_task(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
"""Capture run_task invocations instead of spinning up RpcClient."""
captured: list[dict] = []
async def _stub(**kwargs):
captured.append(kwargs)
return None
from robomp import tasks as tasks_module
monkeypatch.setattr(tasks_module, "run_task", _stub)
return captured
async def test_handle_pr_conversation_unmapped_bot_pr_uses_pr_branch(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
from robomp import tasks
from robomp.github_client import CommentInfo, IssueInfo, PullRequestInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
pr_issue = IssueInfo(
repo="octo/widget",
number=900,
title="Fix flaky parser",
body="PR body",
state="open",
author=settings.bot_login,
labels=(),
is_pull_request=True,
)
pr_info = PullRequestInfo(
repo="octo/widget",
number=900,
html_url="https://github.com/octo/widget/pull/900",
head_ref="farm/abc12345/fix-flaky-parser",
base_ref="main",
state="open",
author=settings.bot_login,
head_repo="octo/widget",
)
async def _get_pull_request(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return pr_info
async def _get_repo(self, repo_full: str):
assert repo_full == "octo/widget"
return repo
async def _get_issue(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return pr_issue
async def _list_comments(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return [CommentInfo(id=77, author="can1357", body="prior PR context", created_at="2026-05-14T00:00:00Z")]
async def _list_review_comments(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return []
async def _list_pr_reviews(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return []
monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request)
monkeypatch.setattr(GitHubClient, "get_repo", _get_repo)
monkeypatch.setattr(GitHubClient, "get_issue", _get_issue)
monkeypatch.setattr(GitHubClient, "list_comments", _list_comments)
monkeypatch.setattr(GitHubClient, "list_review_comments", _list_review_comments)
monkeypatch.setattr(GitHubClient, "list_pr_reviews", _list_pr_reviews)
payload = {
"action": "created",
"issue": {"number": 900, "pull_request": {"url": "https://api.github.com/repos/octo/widget/pulls/900"}},
"comment": {"user": {"login": "can1357"}, "body": "please fix", "id": 10, "created_at": "2026-05-15T00:00:00Z"},
"repository": {"full_name": "octo/widget"},
}
await tasks.handle_pr_conversation(
settings=settings,
db=db,
github=GitHubClient("t"),
git_transport=LocalGitTransport(token=None),
sandbox=sandbox,
payload=payload,
delivery_id="test-pr-direct",
)
assert len(stub_run_task) == 1
call = stub_run_task[0]
assert call["task_kind"] == "handle_comment"
assert call["pr_number"] == 900
assert call["inputs"].issue.is_pull_request is True
assert [(m.kind, m.author, m.body) for m in call["thread"]] == [
("pr_body", settings.bot_login, "PR body"),
("comment", "can1357", "prior PR context"),
]
assert sandbox.ensure_calls[0]["number"] == 900
assert sandbox.ensure_calls[0]["existing_branch"] == "farm/abc12345/fix-flaky-parser"
row = db.get_issue("octo/widget#900")
assert row is not None
assert row.pr_number == 900
assert row.branch == "farm/abc12345/fix-flaky-parser"
close_database()
async def test_handle_pr_conversation_repairs_missing_pr_mapping_from_branch(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
from robomp import tasks
from robomp.github_client import CommentInfo, IssueInfo, PullRequestInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
branch = "farm/abc12345/fix-flaky-parser"
db.upsert_issue(key="octo/widget#42", repo="octo/widget", number=42, state="opened", branch=branch)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=42,
title="Parser is flaky",
body="issue body",
state="open",
author="alice",
labels=(),
is_pull_request=False,
)
pr_issue = IssueInfo(
repo="octo/widget",
number=900,
title="Fix flaky parser",
body="PR body",
state="open",
author=settings.bot_login,
labels=(),
is_pull_request=True,
)
pr_info = PullRequestInfo(
repo="octo/widget",
number=900,
html_url="https://github.com/octo/widget/pull/900",
head_ref=branch,
base_ref="main",
state="open",
author=settings.bot_login,
head_repo="octo/widget",
)
async def _get_pull_request(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return pr_info
async def _get_repo(self, repo_full: str):
assert repo_full == "octo/widget"
return repo
async def _get_issue(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
if number == 42:
return issue
if number == 900:
return pr_issue
raise AssertionError(number)
async def _list_comments(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return [CommentInfo(id=78, author="can1357", body="prior PR context", created_at="2026-05-14T00:00:00Z")]
async def _list_review_comments(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return []
async def _list_pr_reviews(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return []
monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request)
monkeypatch.setattr(GitHubClient, "get_repo", _get_repo)
monkeypatch.setattr(GitHubClient, "get_issue", _get_issue)
monkeypatch.setattr(GitHubClient, "list_comments", _list_comments)
monkeypatch.setattr(GitHubClient, "list_review_comments", _list_review_comments)
monkeypatch.setattr(GitHubClient, "list_pr_reviews", _list_pr_reviews)
payload = {
"action": "created",
"issue": {"number": 900, "pull_request": {"url": "https://api.github.com/repos/octo/widget/pulls/900"}},
"comment": {"user": {"login": "can1357"}, "body": "please fix", "id": 11, "created_at": "2026-05-15T00:00:00Z"},
"repository": {"full_name": "octo/widget"},
}
await tasks.handle_pr_conversation(
settings=settings,
db=db,
github=GitHubClient("t"),
git_transport=LocalGitTransport(token=None),
sandbox=sandbox,
payload=payload,
delivery_id="test-pr-repair",
)
assert len(stub_run_task) == 1
call = stub_run_task[0]
assert call["inputs"].issue.number == 42
assert call["pr_number"] == 900
assert [(m.kind, m.author, m.body) for m in call["thread"]] == [
("pr_body", settings.bot_login, "PR body"),
("comment", "can1357", "prior PR context"),
]
assert sandbox.ensure_calls[0]["number"] == 42
assert sandbox.ensure_calls[0]["existing_branch"] == branch
row = db.get_issue("octo/widget#42")
assert row is not None
assert row.pr_number == 900
close_database()
async def test_handle_pr_conversation_skips_review_workspace_rows(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
from robomp import tasks
from robomp.github_client import GitHubClient, PullRequestInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
db.upsert_issue(
key="octo/widget#900",
repo="octo/widget",
number=900,
state="reviewing",
branch="review/pr-900",
pr_number=900,
)
async def _get_pull_request(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return PullRequestInfo(
repo="octo/widget",
number=900,
html_url="https://github.com/octo/widget/pull/900",
head_ref="contrib/fix",
base_ref="main",
state="open",
author="alice",
head_repo="alice/widget",
)
monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request)
payload = {
"action": "created",
"issue": {
"number": 900,
"user": {"login": "alice"},
"pull_request": {"url": "https://api.github.com/repos/octo/widget/pulls/900"},
},
"comment": {"user": {"login": "can1357"}, "body": "@robomp-bot please re-review", "id": 12},
"repository": {"full_name": "octo/widget"},
"_robomp_directive": {"body": "please re-review", "author": "can1357"},
}
await tasks.handle_pr_conversation(
settings=settings,
db=db,
github=GitHubClient("t"),
git_transport=LocalGitTransport(token=None),
sandbox=sandbox,
payload=payload,
delivery_id="test-pr-review-row",
)
assert stub_run_task == []
assert sandbox.ensure_calls == []
close_database()
async def test_review_pr_retries_when_ranked_but_not_submitted(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
from robomp import tasks
from robomp.github_client import GitHubClient, IssueInfo, PullRequestInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=900,
title="Fix parser",
body="body",
state="open",
author="alice",
labels=("triaged", "review:p1"),
is_pull_request=True,
)
pr = PullRequestInfo(
repo="octo/widget",
number=900,
html_url="https://github.com/octo/widget/pull/900",
head_ref="alice/fix-parser",
base_ref="main",
state="open",
author="alice",
head_repo="alice/widget",
)
async def _get_repo(self, repo_full: str):
assert repo_full == "octo/widget"
return repo
async def _get_issue(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return issue
async def _get_pull_request(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return pr
monkeypatch.setattr(GitHubClient, "get_repo", _get_repo)
monkeypatch.setattr(GitHubClient, "get_issue", _get_issue)
monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request)
await tasks.review_pr(
settings=settings,
db=db,
github=GitHubClient("t"),
sandbox=sandbox,
git_transport=LocalGitTransport(token=None),
payload={"pull_request": {"number": 900}, "repository": {"full_name": "octo/widget"}},
delivery_id="d-review-retry",
)
assert len(stub_run_task) == 1
assert stub_run_task[0]["task_kind"] == "review_pr"
assert sandbox.ensure_calls[0]["pr_head"] == 900
close_database()
async def test_review_pr_skips_after_submitted_review(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
from robomp import tasks
from robomp.github_client import GitHubClient, IssueInfo, PullRequestInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
key = issue_key("octo/widget", 900)
db.log_tool_call(issue_key=key, tool="submit_pr_review", args={"body": "done"}, result={"review_id": 12})
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=900,
title="Fix parser",
body="body",
state="open",
author="alice",
labels=("triaged", "review:p1"),
is_pull_request=True,
)
pr = PullRequestInfo(
repo="octo/widget",
number=900,
html_url="https://github.com/octo/widget/pull/900",
head_ref="alice/fix-parser",
base_ref="main",
state="open",
author="alice",
head_repo="alice/widget",
)
async def _get_repo(self, repo_full: str):
assert repo_full == "octo/widget"
return repo
async def _get_issue(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return issue
async def _get_pull_request(self, repo_full: str, number: int):
assert repo_full == "octo/widget"
assert number == 900
return pr
monkeypatch.setattr(GitHubClient, "get_repo", _get_repo)
monkeypatch.setattr(GitHubClient, "get_issue", _get_issue)
monkeypatch.setattr(GitHubClient, "get_pull_request", _get_pull_request)
await tasks.review_pr(
settings=settings,
db=db,
github=GitHubClient("t"),
sandbox=sandbox,
git_transport=LocalGitTransport(token=None),
payload={"pull_request": {"number": 900}, "repository": {"full_name": "octo/widget"}},
delivery_id="d-review-skip",
)
assert stub_run_task == []
assert sandbox.ensure_calls == []
close_database()
async def test_handle_comment_directive_bootstraps_untriaged_issue(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
"""Directive on an unknown issue → DB row created, triage_issue task with directive."""
from robomp import tasks
from robomp.github_client import GitHubClient, IssueInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=88,
title="boom",
body="details",
state="open",
author="alice",
labels=(),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
payload = {
"action": "created",
"issue": {"number": 88, "user": {"login": "alice"}, "title": "boom"},
"comment": {"user": {"login": "can1357"}, "body": "do it", "id": 1, "created_at": "2026-05-14T20:00:00Z"},
"repository": {"full_name": "octo/widget"},
"_robomp_directive": {"body": "please refactor X", "author": "can1357"},
}
await tasks.handle_comment(
settings=settings,
db=db,
github=GitHubClient("t"),
git_transport=LocalGitTransport(token=None),
sandbox=sandbox,
payload=payload,
delivery_id="test-delivery-1",
)
assert len(stub_run_task) == 1
call = stub_run_task[0]
assert call["task_kind"] == "triage_issue"
assert call["directive"] is not None
assert call["directive"].body == "please refactor X"
assert call["directive"].author == "can1357"
row = db.get_issue("octo/widget#88")
assert row is not None
assert row.state == "reproducing"
assert sandbox.ensure_calls, "ensure_workspace must be called"
assert sandbox.remove_calls == [], "no removal on bootstrap"
close_database()
async def test_handle_comment_directive_reopens_finalized_issue(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
"""Directive on a closed issue → workspace torn down, state reset, no auto-reply."""
from robomp import tasks
from robomp.github_client import GitHubClient, IssueInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
db.upsert_issue(
key="octo/widget#88", repo="octo/widget", number=88, state="closed", branch="farm/old/branch", pr_number=99
)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=88,
title="boom",
body="details",
state="open",
author="alice",
labels=(),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
post_comment_calls: list = []
async def _no_post_comment(*args, **kwargs):
post_comment_calls.append((args, kwargs))
return None
monkeypatch.setattr(GitHubClient, "post_comment", _no_post_comment)
payload = {
"action": "created",
"issue": {"number": 88, "user": {"login": "alice"}, "title": "boom"},
"comment": {"user": {"login": "can1357"}, "body": "redo", "id": 2, "created_at": "2026-05-14T21:00:00Z"},
"repository": {"full_name": "octo/widget"},
"_robomp_directive": {"body": "redo the fix", "author": "can1357"},
}
await tasks.handle_comment(
settings=settings,
db=db,
github=GitHubClient("t"),
git_transport=LocalGitTransport(token=None),
sandbox=sandbox,
payload=payload,
delivery_id="test-delivery-2",
)
assert len(stub_run_task) == 1
call = stub_run_task[0]
assert call["task_kind"] == "handle_comment"
assert call["directive"].body == "redo the fix"
assert sandbox.remove_calls == [("octo/widget", 88)]
assert sandbox.ensure_calls
# Reopen branches afresh (no existing_branch passed).
assert sandbox.ensure_calls[0]["existing_branch"] is None
assert post_comment_calls == [], "no 'this is closed' comment on reopen"
row = db.get_issue("octo/widget#88")
assert row is not None and row.state == "reproducing"
close_database()
async def test_handle_comment_finalized_without_directive_still_replies(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
"""Non-maintainer on a closed issue → original behavior preserved."""
from robomp import tasks
from robomp.github_client import GitHubClient, IssueInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
db.upsert_issue(
key="octo/widget#88", repo="octo/widget", number=88, state="closed", branch="farm/old/branch", pr_number=99
)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=88,
title="boom",
body="details",
state="closed",
author="alice",
labels=(),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
post_comment_calls: list = []
async def _capture_post(self, *args, **kwargs):
post_comment_calls.append((args, kwargs))
return None
monkeypatch.setattr(GitHubClient, "post_comment", _capture_post)
payload = {
"action": "created",
"issue": {"number": 88, "user": {"login": "stranger"}, "title": "boom"},
"comment": {
"user": {"login": "stranger"},
"body": "still broken",
"id": 3,
"created_at": "2026-05-14T22:00:00Z",
},
"repository": {"full_name": "octo/widget"},
}
await tasks.handle_comment(
settings=settings,
db=db,
github=GitHubClient("t"),
git_transport=LocalGitTransport(token=None),
sandbox=sandbox,
payload=payload,
delivery_id="test-delivery-3",
)
assert stub_run_task == [], "must not invoke run_task on plain finalized comment"
assert post_comment_calls, "should post the finalized-issue reply"
assert sandbox.remove_calls == []
close_database()
async def test_handle_comment_resumes_needs_info_without_preemptive_cleanup(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
"""A needs-info reply resumes first; host tools clear state only after actionable work."""
from robomp import tasks
from robomp.github_client import GitHubClient, IssueInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
db.upsert_issue(
key="octo/widget#88",
repo="octo/widget",
number=88,
state="needs_info",
branch="farm/old/branch",
)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=88,
title="boom",
body="details",
state="open",
author="alice",
labels=("needs-info",),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
post_comment_calls: list = []
removed_labels: list[tuple[str, int, str]] = []
async def _capture_post(self, *args, **kwargs):
post_comment_calls.append((args, kwargs))
return None
async def _capture_remove_label(self, repo: str, number: int, label: str) -> None:
removed_labels.append((repo, number, label))
monkeypatch.setattr(GitHubClient, "post_comment", _capture_post)
monkeypatch.setattr(GitHubClient, "remove_issue_label", _capture_remove_label)
payload = {
"action": "created",
"issue": {"number": 88, "user": {"login": "alice"}, "title": "boom"},
"comment": {
"user": {"login": "alice"},
"body": "I am on Bun 1.3.14 and here is the trace",
"id": 4,
"created_at": "2026-05-14T23:00:00Z",
},
"repository": {"full_name": "octo/widget"},
}
await tasks.handle_comment(
settings=settings,
db=db,
github=GitHubClient("t"),
git_transport=LocalGitTransport(token=None),
sandbox=sandbox,
payload=payload,
delivery_id="test-delivery-needs-info",
)
assert len(stub_run_task) == 1
call = stub_run_task[0]
assert call["task_kind"] == "handle_comment"
assert call["comment"].body == "I am on Bun 1.3.14 and here is the trace"
assert sandbox.ensure_calls[0]["existing_branch"] == "farm/old/branch"
assert removed_labels == []
assert post_comment_calls == [], "needs-info replies must not get the finalized-issue notice"
row = db.get_issue("octo/widget#88")
assert row is not None and row.state == "needs_info"
close_database()
async def test_directive_handler_attaches_thread_from_github(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
"""When a directive lands, the handler must hydrate the thread before run_task."""
from robomp import tasks
from robomp.github_client import (
CommentInfo,
GitHubClient,
IssueInfo,
RepoInfo,
)
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=88,
title="boom",
body="the body",
state="open",
author="alice",
labels=(),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
# Stub GitHubClient endpoints used by _fetch_thread.
async def _get_issue(self, _repo, _number):
return issue
async def _list_comments(self, _repo, _number):
return [
CommentInfo(id=1, author="alice", body="me too", created_at="2026-05-01T10:00:00Z"),
CommentInfo(id=2, author="bob", body="confirmed", created_at="2026-05-02T10:00:00Z"),
]
monkeypatch.setattr(GitHubClient, "get_issue", _get_issue)
monkeypatch.setattr(GitHubClient, "list_comments", _list_comments)
payload = {
"action": "created",
"issue": {"number": 88, "user": {"login": "alice"}, "title": "boom"},
"comment": {
"user": {"login": "can1357"},
"body": "@roboomp do X",
"id": 10,
"created_at": "2026-05-03T20:00:00Z",
},
"repository": {"full_name": "octo/widget"},
"_robomp_directive": {"body": "do X", "author": "can1357"},
}
# Pre-seed an issue row so we exercise the "existing, non-finalized" path
# (otherwise we'd hit the bootstrap branch which is covered elsewhere).
db.upsert_issue(key="octo/widget#88", repo="octo/widget", number=88, state="reproducing", branch="farm/x/y")
await tasks.handle_comment(
settings=settings,
db=db,
github=GitHubClient("t"),
git_transport=LocalGitTransport(token=None),
sandbox=sandbox,
payload=payload,
delivery_id="test-delivery-4",
)
assert len(stub_run_task) == 1
directive = stub_run_task[0]["directive"]
assert directive is not None
assert directive.body == "do X"
# Thread must include the body + both comments, in chronological order.
kinds_authors = [(m.kind, m.author) for m in directive.thread]
assert ("issue_body", "alice") in kinds_authors
assert ("comment", "alice") in kinds_authors
assert ("comment", "bob") in kinds_authors
close_database()
# ---------- triage_issue: closing-PR skip ----------
class _StubGithubForTriage:
"""Minimal `GitHubBackend` shim for triage_issue tests."""
def __init__(self, closing_prs: tuple[int, ...] | Exception = ()) -> None:
self._closing_prs = closing_prs
self.calls: list[tuple[str, int]] = []
async def list_closing_pull_requests(self, repo: str, number: int) -> tuple[int, ...]:
self.calls.append((repo, number))
if isinstance(self._closing_prs, Exception):
raise self._closing_prs
return self._closing_prs
async def test_triage_issue_skips_when_a_closing_pr_already_exists(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
"""An OPEN PR linked via Closes/Fixes/Resolves means another author is on it.
The bot MUST NOT triage, label, or build a workspace — leave it alone."""
from robomp import tasks
from robomp.github_client import IssueInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=1069,
title="x",
body="y",
state="open",
author="alice",
labels=(),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
github = _StubGithubForTriage(closing_prs=(1070,))
await tasks.triage_issue(
settings=settings,
db=db,
github=github, # type: ignore[arg-type]
git_transport=LocalGitTransport(token=None),
sandbox=sandbox, # type: ignore[arg-type]
payload={"action": "opened", "issue": {"number": 1069}, "repository": {"full_name": "octo/widget"}},
delivery_id="t-skip-1",
)
assert github.calls == [("octo/widget", 1069)]
assert stub_run_task == [], "must not invoke run_task when a closing PR exists"
assert sandbox.ensure_calls == [], "must not create a workspace when skipping"
assert db.get_issue("octo/widget#1069") is None, "must not persist an issue row when skipping"
close_database()
async def test_triage_issue_proceeds_when_no_closing_pr(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
from robomp import tasks
from robomp.github_client import IssueInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=42,
title="x",
body="y",
state="open",
author="alice",
labels=(),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
github = _StubGithubForTriage(closing_prs=())
await tasks.triage_issue(
settings=settings,
db=db,
github=github, # type: ignore[arg-type]
git_transport=LocalGitTransport(token=None),
sandbox=sandbox, # type: ignore[arg-type]
payload={"action": "opened", "issue": {"number": 42}, "repository": {"full_name": "octo/widget"}},
delivery_id="t-skip-2",
)
assert github.calls == [("octo/widget", 42)]
assert len(stub_run_task) == 1
assert sandbox.ensure_calls, "workspace must be created when no closing PR"
row = db.get_issue("octo/widget#42")
assert row is not None and row.state == "reproducing"
close_database()
async def test_triage_issue_fails_open_when_timeline_fetch_errors(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
"""A transient timeline fetch failure MUST NOT block legitimate triage."""
from robomp import tasks
from robomp.github_client import GitHubError, IssueInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=99,
title="x",
body="y",
state="open",
author="alice",
labels=(),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
github = _StubGithubForTriage(closing_prs=GitHubError(503, "upstream timeout"))
await tasks.triage_issue(
settings=settings,
db=db,
github=github, # type: ignore[arg-type]
git_transport=LocalGitTransport(token=None),
sandbox=sandbox, # type: ignore[arg-type]
payload={"action": "opened", "issue": {"number": 99}, "repository": {"full_name": "octo/widget"}},
delivery_id="t-skip-3",
)
assert len(stub_run_task) == 1, "transient timeline error must fail open"
assert sandbox.ensure_calls
close_database()
async def test_triage_issue_does_not_recheck_when_issue_row_exists(
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
) -> None:
"""A second triage of the same issue (e.g. retry/replay) MUST NOT re-query the
timeline — the bot is already committed and the row guards re-entry."""
from robomp import tasks
from robomp.github_client import IssueInfo, RepoInfo
sandbox = _RecordingSandbox(tmp_path)
db = get_database(settings.sqlite_path)
# Pre-seed the issue row as if a prior triage created it.
db.upsert_issue(key="octo/widget#7", repo="octo/widget", number=7, state="reproducing")
repo = RepoInfo(
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
)
issue = IssueInfo(
repo="octo/widget",
number=7,
title="x",
body="y",
state="open",
author="alice",
labels=(),
is_pull_request=False,
)
async def _resolve(_gh, _payload):
return repo, issue
monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve)
github = _StubGithubForTriage(closing_prs=(1070,)) # would skip if called
await tasks.triage_issue(
settings=settings,
db=db,
github=github, # type: ignore[arg-type]
git_transport=LocalGitTransport(token=None),
sandbox=sandbox, # type: ignore[arg-type]
payload={"action": "opened", "issue": {"number": 7}, "repository": {"full_name": "octo/widget"}},
delivery_id="t-skip-4",
)
assert github.calls == [], "MUST NOT query timeline on a repeat-triage"
assert len(stub_run_task) == 1
close_database()
# -------- /webhook/github cancellation hooks --------------------------------
def _post_issue_comment_simple(
client: TestClient,
*,
delivery: str,
issue_number: int,
user: str = "alice",
secret: str = "test-webhook-secret",
):
return _post_issue_comment(
client,
delivery=delivery,
user=user,
number=issue_number,
body="follow-up",
secret=secret,
)
def _post_issues_closed(
client: TestClient,
*,
delivery: str,
issue_number: int,
secret: str = "test-webhook-secret",
):
payload = {
"action": "closed",
"issue": {"number": issue_number, "user": {"login": "alice"}},
"repository": {"full_name": "octo/widget"},
}
body = json.dumps(payload).encode()
return client.post(
"/webhook/github",
content=body,
headers=_signed_headers(secret, body, event="issues", delivery=delivery),
)
def _seed_pending_closure(db, *, key: str, number: int) -> None:
db.upsert_pending_closure(
issue_key=key,
repo="octo/widget",
number=number,
comment_id=42,
issue_author="alice",
close_at="2999-01-01T00:00:00.000000Z",
)
def test_webhook_issue_comment_cancels_pending_closure(settings: Settings) -> None:
db = get_database(settings.sqlite_path)
key = issue_key("octo/widget", 7)
_seed_pending_closure(db, key=key, number=7)
app = create_app(settings)
with TestClient(app) as client:
resp = _post_issue_comment_simple(client, delivery="d-cancel-comment", issue_number=7)
assert resp.status_code == 202
row = db.get_pending_closure(key)
assert row is not None
assert row.state == "cancelled"
assert row.cancel_reason == "user_replied"
close_database()
def test_webhook_issues_closed_cancels_pending_closure(settings: Settings) -> None:
db = get_database(settings.sqlite_path)
key = issue_key("octo/widget", 8)
_seed_pending_closure(db, key=key, number=8)
app = create_app(settings)
with TestClient(app) as client:
resp = _post_issues_closed(client, delivery="d-cancel-closed", issue_number=8)
assert resp.status_code == 202
row = db.get_pending_closure(key)
assert row is not None
assert row.state == "cancelled"
assert row.cancel_reason == "externally_closed"
close_database()
def test_webhook_pr_conversation_does_not_cancel_pending_closure(settings: Settings) -> None:
"""A comment on a PR (issue payload with `pull_request`) routes to
`handle_pr_conversation`, which is unrelated to the question auto-close
schedule on the originating issue."""
db = get_database(settings.sqlite_path)
key = issue_key("octo/widget", 9)
_seed_pending_closure(db, key=key, number=9)
app = create_app(settings)
with TestClient(app) as client:
# Use the existing PR-issue-comment helper (number 9 here is the PR).
resp = _post_pr_issue_comment(
client,
delivery="d-pr-noop",
user="alice",
pr_number=9,
)
assert resp.status_code == 202
row = db.get_pending_closure(key)
# Row may have been touched only if the PR maps back to issue 9; safest
# assertion: the row stays `pending` because routing went down a path
# other than `handle_comment`.
assert row is not None
assert row.state == "pending"
close_database()