653 lines
23 KiB
Python
653 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
|
|
from robomp.db import Database, iso_seconds_ago, issue_key
|
|
|
|
|
|
def test_record_event_dedupes_by_delivery(db: Database) -> None:
|
|
payload = {"action": "opened", "issue": {"number": 1}}
|
|
assert db.record_event(
|
|
delivery_id="abc",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=issue_key("octo/widget", 1),
|
|
payload=payload,
|
|
)
|
|
assert not db.record_event(
|
|
delivery_id="abc",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=issue_key("octo/widget", 1),
|
|
payload=payload,
|
|
)
|
|
|
|
|
|
def test_claim_next_event_singleton_under_contention(db: Database) -> None:
|
|
for i in range(5):
|
|
db.record_event(
|
|
delivery_id=f"d-{i}",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=issue_key("octo/widget", i),
|
|
payload={"i": i},
|
|
)
|
|
|
|
winners: list[str] = []
|
|
lock = threading.Lock()
|
|
|
|
def claim() -> None:
|
|
row = db.claim_next_event()
|
|
if row is not None:
|
|
with lock:
|
|
winners.append(row.delivery_id)
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
for _ in range(5):
|
|
futures = [pool.submit(claim) for _ in range(8)]
|
|
for f in futures:
|
|
f.result()
|
|
|
|
# Each delivery id should appear exactly once.
|
|
assert sorted(winners) == [f"d-{i}" for i in range(5)]
|
|
assert all(db.get_event(f"d-{i}").state == "running" for i in range(5))
|
|
|
|
|
|
def test_claim_next_event_leaves_same_issue_queued_while_running(db: Database) -> None:
|
|
key = issue_key("octo/widget", 4)
|
|
db.record_event(
|
|
delivery_id="running",
|
|
event_type="issue_comment",
|
|
repo="octo/widget",
|
|
issue_key=key,
|
|
payload={"action": "created"},
|
|
state="running",
|
|
)
|
|
db.record_event(
|
|
delivery_id="queued",
|
|
event_type="issue_comment",
|
|
repo="octo/widget",
|
|
issue_key=key,
|
|
payload={"action": "created"},
|
|
)
|
|
|
|
assert db.claim_next_event() is None
|
|
assert db.get_event("queued").state == "queued"
|
|
|
|
db.mark_event("running", "done")
|
|
claimed = db.claim_next_event()
|
|
assert claimed is not None
|
|
assert claimed.delivery_id == "queued"
|
|
assert db.get_event("queued").state == "running"
|
|
|
|
|
|
def test_claim_next_event_skips_blocked_issue_without_stalling_others(db: Database) -> None:
|
|
blocked = issue_key("octo/widget", 4)
|
|
ready = issue_key("octo/widget", 5)
|
|
db.record_event(
|
|
delivery_id="running",
|
|
event_type="issue_comment",
|
|
repo="octo/widget",
|
|
issue_key=blocked,
|
|
payload={"action": "created"},
|
|
state="running",
|
|
)
|
|
db.record_event(
|
|
delivery_id="blocked",
|
|
event_type="issue_comment",
|
|
repo="octo/widget",
|
|
issue_key=blocked,
|
|
payload={"action": "created"},
|
|
)
|
|
db.record_event(
|
|
delivery_id="ready",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=ready,
|
|
payload={"action": "opened"},
|
|
)
|
|
|
|
claimed = db.claim_next_event()
|
|
assert claimed is not None
|
|
assert claimed.delivery_id == "ready"
|
|
assert db.get_event("blocked").state == "queued"
|
|
|
|
|
|
def test_requeue_event_can_be_restricted_by_source_state(db: Database) -> None:
|
|
db.record_event(
|
|
delivery_id="done-event",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=issue_key("octo/widget", 1),
|
|
payload={},
|
|
state="done",
|
|
)
|
|
db.record_event(
|
|
delivery_id="running-event",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=issue_key("octo/widget", 2),
|
|
payload={},
|
|
state="running",
|
|
)
|
|
|
|
assert db.requeue_event("done-event", from_states=("done", "failed", "skipped"))
|
|
assert db.get_event("done-event").state == "queued"
|
|
|
|
assert not db.requeue_event("running-event", from_states=("done", "failed", "skipped"))
|
|
assert db.get_event("running-event").state == "running"
|
|
|
|
|
|
def test_latest_issue_events_ignore_skipped_noise(db: Database) -> None:
|
|
fixed = issue_key("octo/widget", 1)
|
|
still_failed = issue_key("octo/widget", 2)
|
|
db.record_event(
|
|
delivery_id="fixed-failed",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=fixed,
|
|
payload={"action": "opened"},
|
|
state="failed",
|
|
)
|
|
db.record_event(
|
|
delivery_id="fixed-done",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=fixed,
|
|
payload={"action": "opened"},
|
|
state="done",
|
|
)
|
|
db.record_event(
|
|
delivery_id="failed-run",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=still_failed,
|
|
payload={"action": "opened"},
|
|
state="failed",
|
|
)
|
|
db.record_event(
|
|
delivery_id="label-noise",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=still_failed,
|
|
payload={"action": "labeled"},
|
|
state="skipped",
|
|
last_error="issues.labeled ignored",
|
|
)
|
|
|
|
latest_failed = db.latest_event_for_issue(still_failed)
|
|
latest_raw = db.latest_event_for_issue(still_failed, include_skipped=True)
|
|
assert latest_failed is not None
|
|
assert latest_raw is not None
|
|
assert latest_failed.delivery_id == "failed-run"
|
|
assert latest_raw.delivery_id == "label-noise"
|
|
|
|
latest = db.latest_events_for_issues((fixed, still_failed))
|
|
assert latest[fixed].delivery_id == "fixed-done"
|
|
assert latest[still_failed].delivery_id == "failed-run"
|
|
|
|
counts = db.latest_issue_event_state_counts()
|
|
assert counts["done"] == 1
|
|
assert counts["failed"] == 1
|
|
assert counts["skipped"] == 0
|
|
|
|
|
|
def test_reset_stuck_running_recovers(db: Database) -> None:
|
|
db.record_event(
|
|
delivery_id="d1",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key="octo/widget#1",
|
|
payload={},
|
|
)
|
|
row = db.claim_next_event()
|
|
assert row is not None
|
|
# Capture `started_at` set by the claim so we can prove the recovery flip preserves it.
|
|
with db._lock: # noqa: SLF001
|
|
before = db._conn.execute( # noqa: SLF001
|
|
"SELECT started_at FROM events WHERE delivery_id=?", ("d1",)
|
|
).fetchone()
|
|
assert before is not None
|
|
assert before["started_at"] is not None
|
|
# Simulate crash: row still running.
|
|
recovered = db.reset_stuck_running()
|
|
assert recovered == 1
|
|
assert db.get_event("d1").state == "queued"
|
|
with db._lock: # noqa: SLF001
|
|
after = db._conn.execute( # noqa: SLF001
|
|
"SELECT started_at FROM events WHERE delivery_id=?", ("d1",)
|
|
).fetchone()
|
|
assert after is not None
|
|
assert after["started_at"] == before["started_at"]
|
|
|
|
|
|
def test_upsert_issue_round_trip(db: Database) -> None:
|
|
key = issue_key("octo/widget", 7)
|
|
row = db.upsert_issue(
|
|
key=key,
|
|
repo="octo/widget",
|
|
number=7,
|
|
state="new",
|
|
)
|
|
assert row.state == "new"
|
|
row = db.upsert_issue(
|
|
key=key,
|
|
repo="octo/widget",
|
|
number=7,
|
|
state="opened",
|
|
branch="farm/abcd1234/some-issue",
|
|
session_dir="/tmp/s",
|
|
pr_number=42,
|
|
)
|
|
assert row.state == "opened"
|
|
assert row.branch == "farm/abcd1234/some-issue"
|
|
assert row.pr_number == 42
|
|
fetched = db.get_issue(key)
|
|
assert fetched and fetched.pr_number == 42
|
|
|
|
found = db.find_issue_by_pr("octo/widget", 42)
|
|
assert found and found.key == key
|
|
by_branch = db.find_issue_by_branch("octo/widget", "farm/abcd1234/some-issue")
|
|
assert by_branch and by_branch.key == key
|
|
|
|
|
|
def test_log_tool_call(db: Database) -> None:
|
|
db.upsert_issue(key="octo/widget#1", repo="octo/widget", number=1, state="new")
|
|
row_id = db.log_tool_call(
|
|
issue_key="octo/widget#1",
|
|
tool="gh_post_comment",
|
|
args={"body": "hi"},
|
|
result={"comment_id": 9},
|
|
)
|
|
assert row_id > 0
|
|
|
|
|
|
def test_pr_review_comment_staging_round_trip(db: Database) -> None:
|
|
first = db.stage_review_comment(
|
|
issue_key="octo/widget#9",
|
|
path="src/app.py",
|
|
line=12,
|
|
side="RIGHT",
|
|
start_line=10,
|
|
start_side="RIGHT",
|
|
body="blocking finding",
|
|
)
|
|
db.stage_review_comment(
|
|
issue_key="octo/widget#9",
|
|
path="src/other.py",
|
|
line=3,
|
|
body="nit",
|
|
)
|
|
db.stage_review_comment(issue_key="octo/widget#10", path="x.py", line=1, body="other")
|
|
|
|
rows = db.list_staged_review_comments("octo/widget#9")
|
|
assert [row.id for row in rows] == [first.id, first.id + 1]
|
|
assert rows[0].path == "src/app.py"
|
|
assert rows[0].start_line == 10
|
|
assert rows[0].start_side == "RIGHT"
|
|
assert rows[1].side == "RIGHT"
|
|
|
|
assert db.clear_staged_review_comments("octo/widget#9") == 2
|
|
assert db.list_staged_review_comments("octo/widget#9") == []
|
|
assert len(db.list_staged_review_comments("octo/widget#10")) == 1
|
|
|
|
|
|
def test_processed_issue_keys_returns_only_known(db: Database) -> None:
|
|
db.upsert_issue(key=issue_key("octo/widget", 1), repo="octo/widget", number=1, state="new")
|
|
db.upsert_issue(key=issue_key("octo/widget", 2), repo="octo/widget", number=2, state="reproducing")
|
|
queried = [
|
|
issue_key("octo/widget", 1),
|
|
issue_key("octo/widget", 2),
|
|
issue_key("octo/widget", 3), # never upserted
|
|
issue_key("octo/other", 7), # different repo, never upserted
|
|
]
|
|
result = db.processed_issue_keys(queried)
|
|
assert result == {issue_key("octo/widget", 1), issue_key("octo/widget", 2)}
|
|
|
|
|
|
def test_processed_issue_keys_empty_input(db: Database) -> None:
|
|
assert db.processed_issue_keys([]) == set()
|
|
# Empty strings are filtered out, not sent as a parameter.
|
|
assert db.processed_issue_keys(["", ""]) == set()
|
|
|
|
|
|
def test_processed_issue_keys_handles_large_batch(db: Database) -> None:
|
|
# Confirms the 500-batch chunking path (>500 parameters would otherwise hit
|
|
# SQLite's SQLITE_MAX_VARIABLE_NUMBER default of 999 on older builds).
|
|
keys = [issue_key("octo/widget", n) for n in range(1, 750)]
|
|
# Persist only every 3rd one.
|
|
for k, n in zip(keys, range(1, 750), strict=True):
|
|
if n % 3 == 0:
|
|
db.upsert_issue(key=k, repo="octo/widget", number=n, state="new")
|
|
result = db.processed_issue_keys(keys + ["bogus#1"])
|
|
expected = {issue_key("octo/widget", n) for n in range(1, 750) if n % 3 == 0}
|
|
assert result == expected
|
|
|
|
|
|
def test_classification_roundtrip(db: Database) -> None:
|
|
key = issue_key("octo/widget", 7)
|
|
db.upsert_issue(key=key, repo="octo/widget", number=7, state="new")
|
|
row = db.get_issue(key)
|
|
assert row is not None and row.classification is None
|
|
db.set_issue_classification(key, "question")
|
|
row = db.get_issue(key)
|
|
assert row is not None and row.classification == "question"
|
|
# Round-trip via list_issues too.
|
|
items = db.list_issues()
|
|
assert any(r.key == key and r.classification == "question" for r in items)
|
|
|
|
|
|
def test_migration_adds_classification_to_existing_db(tmp_path: Path) -> None:
|
|
"""Open a DB without the classification column and verify the migration."""
|
|
import sqlite3
|
|
|
|
path = tmp_path / "legacy.sqlite"
|
|
conn = sqlite3.connect(str(path))
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE events (delivery_id TEXT PRIMARY KEY, event_type TEXT, payload_json TEXT,
|
|
received_at TEXT, state TEXT CHECK(state IN ('queued','running','done','failed','skipped')),
|
|
attempts INTEGER DEFAULT 0, last_error TEXT, repo TEXT, issue_key TEXT,
|
|
started_at TEXT, finished_at TEXT);
|
|
CREATE TABLE issues (key TEXT PRIMARY KEY, repo TEXT, number INTEGER, branch TEXT,
|
|
session_dir TEXT, pr_number INTEGER, state TEXT, updated_at TEXT);
|
|
CREATE TABLE tool_calls (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_key TEXT,
|
|
tool TEXT, args_json TEXT, result_json TEXT, error TEXT, ts TEXT);
|
|
INSERT INTO issues VALUES ('octo/widget#1', 'octo/widget', 1, 'farm/x', '/tmp/s', NULL,
|
|
'reproducing', '2026-01-01T00:00:00Z');
|
|
"""
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
# Opening through our Database class should auto-migrate.
|
|
database = Database(path)
|
|
row = database.get_issue("octo/widget#1")
|
|
assert row is not None
|
|
assert row.classification is None # column exists, default NULL
|
|
database.set_issue_classification("octo/widget#1", "bug")
|
|
assert database.get_issue("octo/widget#1").classification == "bug"
|
|
database.close()
|
|
|
|
|
|
def test_set_event_model_persists_on_running_event(db: Database) -> None:
|
|
"""`set_event_model` writes the picked model so the dashboard can attribute behavior."""
|
|
db.record_event(
|
|
delivery_id="d-model",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=issue_key("octo/widget", 42),
|
|
payload={"action": "opened"},
|
|
)
|
|
row = db.claim_next_event()
|
|
assert row is not None and row.delivery_id == "d-model"
|
|
db.set_event_model("d-model", "claude-sonnet-4-5")
|
|
running = db.list_running_events()
|
|
assert len(running) == 1
|
|
assert running[0]["model"] == "claude-sonnet-4-5"
|
|
# Setting a different model later (e.g. retry) overwrites in place.
|
|
db.set_event_model("d-model", "claude-opus-4-5")
|
|
running = db.list_running_events()
|
|
assert running[0]["model"] == "claude-opus-4-5"
|
|
|
|
|
|
def test_list_running_events_surfaces_last_tool_since_start(db: Database) -> None:
|
|
"""`list_running_events` joins the most recent tool_call newer than `started_at`.
|
|
|
|
Tool calls logged before the current run (e.g. an earlier triage on the same
|
|
issue) MUST NOT be reported as the current activity.
|
|
"""
|
|
key = issue_key("octo/widget", 7)
|
|
db.upsert_issue(key=key, repo="octo/widget", number=7, state="reproducing")
|
|
# Stale tool call from a previous run (no started_at yet).
|
|
db.log_tool_call(issue_key=key, tool="stale_tool", args={})
|
|
db.record_event(
|
|
delivery_id="d-7",
|
|
event_type="issues",
|
|
repo="octo/widget",
|
|
issue_key=key,
|
|
payload={"action": "opened"},
|
|
)
|
|
db.claim_next_event() # sets started_at
|
|
# Before any current-run tool call: last_tool must be NULL, not "stale_tool".
|
|
running = db.list_running_events()
|
|
assert len(running) == 1
|
|
assert running[0]["last_tool"] is None
|
|
assert running[0]["last_tool_ts"] is None
|
|
# New tool call after start → surfaces in the snapshot.
|
|
db.log_tool_call(issue_key=key, tool="gh_post_comment", args={"body": "hi"})
|
|
db.log_tool_call(issue_key=key, tool="set_issue_labels", args={"labels": ["bug"]})
|
|
running = db.list_running_events()
|
|
assert running[0]["last_tool"] == "set_issue_labels" # latest by ts
|
|
assert running[0]["last_tool_ts"] is not None
|
|
|
|
|
|
def test_record_submission_dedupes_by_delivery(db: Database) -> None:
|
|
assert db.record_submission(delivery_id="d-1", login="Alice", repo="octo/widget")
|
|
# Retry of the same delivery id is a no-op (idempotent webhook delivery).
|
|
assert not db.record_submission(delivery_id="d-1", login="alice", repo="octo/widget")
|
|
|
|
|
|
def test_admit_submission_dedupes_by_delivery_before_rate_limit(db: Database) -> None:
|
|
since = iso_seconds_ago(60)
|
|
first = db.admit_submission(
|
|
delivery_id="d-1",
|
|
login="Alice",
|
|
repo="octo/widget",
|
|
since=since,
|
|
cap=1,
|
|
)
|
|
assert first.accepted
|
|
assert not first.duplicate
|
|
assert first.used == 1
|
|
|
|
duplicate = db.admit_submission(
|
|
delivery_id="d-1",
|
|
login="alice",
|
|
repo="octo/widget",
|
|
since=since,
|
|
cap=1,
|
|
)
|
|
assert duplicate.accepted
|
|
assert duplicate.duplicate
|
|
assert duplicate.used == 1
|
|
|
|
rejected = db.admit_submission(
|
|
delivery_id="d-2",
|
|
login="ALICE",
|
|
repo="octo/widget",
|
|
since=since,
|
|
cap=1,
|
|
)
|
|
assert not rejected.accepted
|
|
assert not rejected.duplicate
|
|
assert rejected.used == 1
|
|
assert db.count_submissions_since("alice", since) == 1
|
|
|
|
|
|
def test_admit_submission_enforces_cap_atomically_across_connections(tmp_path: Path) -> None:
|
|
path = tmp_path / "admission.sqlite"
|
|
# Pre-warm: open + migrate the schema once so the two racing threads below
|
|
# collide only on `admit_submission` (which is what the test is exercising),
|
|
# not on `Database.__init__`. `executescript(SCHEMA)` flips journal_mode to
|
|
# WAL, which needs a brief exclusive lock — without pre-warming, one
|
|
# thread can lose that race and never reach `barrier.wait()`, deadlocking
|
|
# its peer at the barrier (no timeout) and hanging `future.result()`.
|
|
Database(path).close()
|
|
barrier = threading.Barrier(2, timeout=10)
|
|
|
|
def admit(delivery_id: str) -> bool:
|
|
database = Database(path)
|
|
try:
|
|
barrier.wait()
|
|
return database.admit_submission(
|
|
delivery_id=delivery_id,
|
|
login="alice",
|
|
repo="octo/widget",
|
|
since=iso_seconds_ago(60),
|
|
cap=1,
|
|
).accepted
|
|
finally:
|
|
database.close()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
futures = [pool.submit(admit, f"d-{i}") for i in range(2)]
|
|
accepted = [future.result(timeout=15) for future in futures]
|
|
|
|
verifier = Database(path)
|
|
try:
|
|
assert sorted(accepted) == [False, True]
|
|
assert verifier.count_submissions_since("alice", iso_seconds_ago(60)) == 1
|
|
finally:
|
|
verifier.close()
|
|
|
|
|
|
def test_count_submissions_since_is_case_insensitive(db: Database) -> None:
|
|
db.record_submission(delivery_id="d-1", login="Alice", repo="octo/widget")
|
|
db.record_submission(delivery_id="d-2", login="ALICE", repo="octo/widget")
|
|
db.record_submission(delivery_id="d-3", login="bob", repo="octo/widget")
|
|
# Window covering the whole test run.
|
|
since = iso_seconds_ago(60)
|
|
assert db.count_submissions_since("alice", since) == 2
|
|
assert db.count_submissions_since("ALICE", since) == 2
|
|
assert db.count_submissions_since("bob", since) == 1
|
|
assert db.count_submissions_since("nobody", since) == 0
|
|
|
|
|
|
def test_count_submissions_since_respects_window(db: Database) -> None:
|
|
db.record_submission(delivery_id="d-1", login="alice", repo="octo/widget")
|
|
# Future cutoff means the just-inserted row is *before* the window.
|
|
future = iso_seconds_ago(-60)
|
|
assert db.count_submissions_since("alice", future) == 0
|
|
|
|
|
|
# -------- pending_closures ---------------------------------------------
|
|
|
|
|
|
_KEY = issue_key("octo/widget", 42)
|
|
|
|
|
|
def _seed_pending(db: Database, *, close_at: str = "2026-05-15T00:00:00.000000Z") -> None:
|
|
db.upsert_pending_closure(
|
|
issue_key=_KEY,
|
|
repo="octo/widget",
|
|
number=42,
|
|
comment_id=999,
|
|
issue_author="Alice",
|
|
close_at=close_at,
|
|
)
|
|
|
|
|
|
def test_upsert_pending_closure_lowercases_author_and_starts_pending(db: Database) -> None:
|
|
_seed_pending(db)
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None
|
|
assert row.state == "pending"
|
|
assert row.cancel_reason is None
|
|
assert row.issue_author == "alice" # author stored lower-cased for cheap eq
|
|
assert row.comment_id == 999
|
|
|
|
|
|
def test_upsert_pending_closure_overwrites_prior_schedule(db: Database) -> None:
|
|
_seed_pending(db)
|
|
db.finalize_closure(_KEY, state="cancelled", reason="user_replied")
|
|
# A follow-up bot answer should reset the row to pending and update fields.
|
|
db.upsert_pending_closure(
|
|
issue_key=_KEY,
|
|
repo="octo/widget",
|
|
number=42,
|
|
comment_id=1234,
|
|
issue_author="alice",
|
|
close_at="2030-01-01T00:00:00.000000Z",
|
|
)
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None
|
|
assert row.state == "pending"
|
|
assert row.cancel_reason is None
|
|
assert row.comment_id == 1234
|
|
assert row.close_at == "2030-01-01T00:00:00.000000Z"
|
|
|
|
|
|
def test_claim_due_closures_only_returns_due_pending(db: Database) -> None:
|
|
_seed_pending(db, close_at="2000-01-01T00:00:00.000000Z") # past
|
|
db.upsert_pending_closure(
|
|
issue_key=issue_key("octo/widget", 7),
|
|
repo="octo/widget",
|
|
number=7,
|
|
comment_id=10,
|
|
issue_author="bob",
|
|
close_at="2999-01-01T00:00:00.000000Z", # future
|
|
)
|
|
claimed = db.claim_due_closures(now="2026-05-15T00:00:00.000000Z")
|
|
assert [r.issue_key for r in claimed] == [_KEY]
|
|
assert all(r.state == "claimed" for r in claimed)
|
|
# And re-claiming returns nothing because the first one is no longer pending.
|
|
again = db.claim_due_closures(now="2026-05-15T00:00:00.000000Z")
|
|
assert again == []
|
|
|
|
|
|
def test_claim_due_closures_atomic_under_contention(db: Database) -> None:
|
|
"""Two concurrent claims see disjoint rows."""
|
|
for n in range(5):
|
|
db.upsert_pending_closure(
|
|
issue_key=issue_key("octo/widget", n),
|
|
repo="octo/widget",
|
|
number=n,
|
|
comment_id=100 + n,
|
|
issue_author="alice",
|
|
close_at="2000-01-01T00:00:00.000000Z",
|
|
)
|
|
seen: list[str] = []
|
|
lock = threading.Lock()
|
|
|
|
def claim_some() -> None:
|
|
rows = db.claim_due_closures(now="2026-05-15T00:00:00.000000Z", limit=2)
|
|
with lock:
|
|
seen.extend(r.issue_key for r in rows)
|
|
|
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
for _ in range(4):
|
|
list(pool.map(lambda _: claim_some(), range(4)))
|
|
# Each row must appear at most once across all claims.
|
|
assert sorted(seen) == sorted({issue_key("octo/widget", n) for n in range(5)})
|
|
|
|
|
|
def test_cancel_pending_closure_only_fires_when_pending(db: Database) -> None:
|
|
_seed_pending(db)
|
|
assert db.cancel_pending_closure(_KEY, reason="user_replied")
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None
|
|
assert row.state == "cancelled"
|
|
assert row.cancel_reason == "user_replied"
|
|
# A second cancel against an already-cancelled row is a no-op.
|
|
assert not db.cancel_pending_closure(_KEY, reason="user_replied")
|
|
|
|
|
|
def test_cancel_pending_closure_skips_claimed_rows(db: Database) -> None:
|
|
"""A `claimed` row must be left for the scheduler tick that owns it."""
|
|
_seed_pending(db, close_at="2000-01-01T00:00:00.000000Z")
|
|
claimed = db.claim_due_closures(now="2026-05-15T00:00:00.000000Z")
|
|
assert claimed and claimed[0].state == "claimed"
|
|
assert not db.cancel_pending_closure(_KEY, reason="user_replied")
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None and row.state == "claimed"
|
|
|
|
|
|
def test_finalize_closure_rejects_non_terminal_state(db: Database) -> None:
|
|
_seed_pending(db)
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError):
|
|
db.finalize_closure(_KEY, state="pending", reason=None) # type: ignore[arg-type]
|
|
|
|
|
|
def test_requeue_claimed_closure_only_flips_claimed(db: Database) -> None:
|
|
_seed_pending(db, close_at="2000-01-01T00:00:00.000000Z")
|
|
db.claim_due_closures(now="2026-05-15T00:00:00.000000Z")
|
|
assert db.requeue_claimed_closure(_KEY)
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None and row.state == "pending"
|
|
# Now in pending state, requeue is a no-op.
|
|
assert not db.requeue_claimed_closure(_KEY)
|