d81fdf8a42
- Test. - Test.
1542 lines
51 KiB
Python
1542 lines
51 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 close_database, get_database, issue_key
|
|
from robomp.github_client import GitHubClient
|
|
from robomp.manual_triage import InvalidIssueRef, 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")
|
|
# A few load-bearing markers from the page; if these vanish, the dashboard
|
|
# changed shape and the rest of the test suite should be updated too.
|
|
assert "<title>robomp</title>" in resp.text
|
|
assert "api/status" in resp.text
|
|
assert "api/logs" in resp.text
|
|
|
|
|
|
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_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_rejects_garbage() -> None:
|
|
for bad in ("widget#1", "octo/widget", "octo/widget#abc", "octo widget#1", ""):
|
|
with pytest.raises(InvalidIssueRef):
|
|
parse_issue_ref(bad)
|
|
|
|
|
|
# ---------- /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=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="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_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="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",
|
|
)
|
|
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 row wins.
|
|
assert body["delivery"] == "d-old-2"
|
|
assert get_database(cfg.sqlite_path).get_event("d-old-2").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_unmapped_pr_comment_does_not_consume_submitter_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 is None
|
|
assert unmapped.last_error is not None
|
|
assert "not mapped" in unmapped.last_error
|
|
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 _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_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
|
|
|
|
|
|
# -------- 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": []}
|
|
|
|
|
|
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"])
|
|
close_database()
|
|
assert states == ["queued"] * 4, states
|
|
|
|
|
|
# -------- handler-level: bootstrap + reopen ----------------------------
|
|
|
|
|
|
class _RecordingSandbox:
|
|
"""Stand-in for SandboxManager: records calls, hands back a fake Workspace."""
|
|
|
|
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,
|
|
author_name: str = "",
|
|
author_email: str = "",
|
|
):
|
|
self.ensure_calls.append(
|
|
{
|
|
"repo": repo,
|
|
"number": number,
|
|
"title": title,
|
|
"default_branch": default_branch,
|
|
"existing_branch": existing_branch,
|
|
}
|
|
)
|
|
# 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"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_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_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()
|