192 lines
6.6 KiB
Python
192 lines
6.6 KiB
Python
"""Coverage for `AutocloseScheduler` against in-process fakes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
|
|
from robomp.autoclose import AutocloseScheduler
|
|
from robomp.config import Settings
|
|
from robomp.db import Database, issue_key
|
|
from robomp.github_client import GitHubError, ReactionInfo
|
|
|
|
|
|
def _settings(*, enabled: bool = True, hours: float = 4.0, scan: float = 60.0) -> Settings:
|
|
return Settings.model_construct(
|
|
github_token=None,
|
|
github_webhook_secret=SecretStr("x"),
|
|
bot_login="robomp-bot",
|
|
git_author_email="bot@example.invalid",
|
|
repo_allowlist_raw="octo/widget",
|
|
gh_proxy_url="http://proxy.invalid",
|
|
gh_proxy_hmac_key=SecretStr("k" * 32),
|
|
question_autoclose_enabled=enabled,
|
|
question_autoclose_hours=hours,
|
|
question_autoclose_scan_seconds=scan,
|
|
)
|
|
|
|
|
|
class _FakeGitHub:
|
|
"""Minimal GitHubBackend stand-in for the scheduler.
|
|
|
|
Only `list_comment_reactions` and `close_issue` are exercised; everything
|
|
else raises so a misuse here surfaces loudly instead of silently.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
reactions: Iterable[ReactionInfo] = (),
|
|
close_error: GitHubError | None = None,
|
|
) -> None:
|
|
self._reactions = tuple(reactions)
|
|
self._close_error = close_error
|
|
self.close_calls: list[tuple[str, int, str]] = []
|
|
self.reaction_calls: list[tuple[str, int]] = []
|
|
|
|
async def list_comment_reactions(self, repo: str, comment_id: int) -> tuple[ReactionInfo, ...]:
|
|
self.reaction_calls.append((repo, comment_id))
|
|
return self._reactions
|
|
|
|
async def close_issue(self, repo: str, number: int, *, reason: str = "completed") -> None:
|
|
self.close_calls.append((repo, number, reason))
|
|
if self._close_error is not None:
|
|
raise self._close_error
|
|
|
|
|
|
_KEY = issue_key("octo/widget", 42)
|
|
|
|
|
|
def _seed(db: Database, *, close_at: str = "2000-01-01T00: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,
|
|
)
|
|
|
|
|
|
async def test_tick_closes_when_no_author_downvote(db: Database) -> None:
|
|
_seed(db)
|
|
gh = _FakeGitHub()
|
|
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
|
counts = await sched.tick()
|
|
assert counts == {"closed": 1, "cancelled": 0, "retried": 0}
|
|
assert gh.close_calls == [("octo/widget", 42, "completed")]
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None and row.state == "closed"
|
|
assert row.cancel_reason is None
|
|
|
|
|
|
async def test_tick_cancels_when_author_downvotes(db: Database) -> None:
|
|
_seed(db)
|
|
gh = _FakeGitHub(
|
|
reactions=[ReactionInfo(content="-1", user_login="Alice", user_type="User")],
|
|
)
|
|
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
|
counts = await sched.tick()
|
|
assert counts == {"closed": 0, "cancelled": 1, "retried": 0}
|
|
assert gh.close_calls == []
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None and row.state == "cancelled"
|
|
assert row.cancel_reason == "author_downvoted"
|
|
|
|
|
|
async def test_tick_ignores_downvote_from_non_author(db: Database) -> None:
|
|
"""Watchers / drive-by 👎 from anyone other than the author do not veto."""
|
|
_seed(db)
|
|
gh = _FakeGitHub(
|
|
reactions=[
|
|
ReactionInfo(content="-1", user_login="rando", user_type="User"),
|
|
ReactionInfo(content="-1", user_login="some-bot", user_type="Bot"),
|
|
],
|
|
)
|
|
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
|
counts = await sched.tick()
|
|
assert counts == {"closed": 1, "cancelled": 0, "retried": 0}
|
|
assert gh.close_calls == [("octo/widget", 42, "completed")]
|
|
|
|
|
|
async def test_tick_retries_after_transient_close_error(db: Database) -> None:
|
|
_seed(db)
|
|
gh = _FakeGitHub(close_error=GitHubError(502, "Bad Gateway"))
|
|
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
|
counts = await sched.tick()
|
|
assert counts == {"closed": 0, "cancelled": 0, "retried": 1}
|
|
row = db.get_pending_closure(_KEY)
|
|
# Failed attempt resets the row to `pending` so the next tick claims it again.
|
|
assert row is not None and row.state == "pending"
|
|
|
|
|
|
async def test_tick_treats_404_close_as_already_closed(db: Database) -> None:
|
|
_seed(db)
|
|
gh = _FakeGitHub(close_error=GitHubError(404, "Not Found"))
|
|
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
|
counts = await sched.tick()
|
|
assert counts == {"closed": 0, "cancelled": 1, "retried": 0}
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None and row.state == "cancelled"
|
|
assert row.cancel_reason == "already_closed"
|
|
|
|
|
|
async def test_tick_retries_when_list_reactions_fails(db: Database) -> None:
|
|
_seed(db)
|
|
|
|
class _ReactBoom(_FakeGitHub):
|
|
async def list_comment_reactions(self, repo, comment_id):
|
|
raise GitHubError(503, "Service Unavailable")
|
|
|
|
gh = _ReactBoom()
|
|
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
|
counts = await sched.tick()
|
|
assert counts == {"closed": 0, "cancelled": 0, "retried": 1}
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None and row.state == "pending"
|
|
|
|
|
|
async def test_tick_skips_future_rows(db: Database) -> None:
|
|
"""A row whose `close_at` is in the future stays pending."""
|
|
_seed(db, close_at="2999-01-01T00:00:00.000000Z")
|
|
gh = _FakeGitHub()
|
|
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
|
counts = await sched.tick()
|
|
assert counts == {"closed": 0, "cancelled": 0, "retried": 0}
|
|
assert gh.close_calls == []
|
|
row = db.get_pending_closure(_KEY)
|
|
assert row is not None and row.state == "pending"
|
|
|
|
|
|
def test_scheduler_disabled_when_feature_off() -> None:
|
|
sched = AutocloseScheduler(
|
|
settings=_settings(enabled=False),
|
|
db=None, # type: ignore[arg-type]
|
|
github=None, # type: ignore[arg-type]
|
|
)
|
|
assert not sched.enabled
|
|
|
|
|
|
def test_scheduler_disabled_when_hours_zero() -> None:
|
|
sched = AutocloseScheduler(
|
|
settings=_settings(hours=0.0),
|
|
db=None, # type: ignore[arg-type]
|
|
github=None, # type: ignore[arg-type]
|
|
)
|
|
assert not sched.enabled
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_is_noop_when_disabled(db: Database) -> None:
|
|
sched = AutocloseScheduler(
|
|
settings=_settings(enabled=False),
|
|
db=db,
|
|
github=_FakeGitHub(),
|
|
)
|
|
await sched.start()
|
|
# No background task should have been created.
|
|
assert sched._task is None # type: ignore[attr-defined]
|
|
await sched.stop() # idempotent
|