"""The `abandon` command: ending a task from any waiting state (A4, B2).""" import io from pathlib import Path from typing import Any import pytest from nisse_dev.control import ( AWAIT_ARCH_SIGNOFF, AWAIT_INPUT, AWAIT_MILESTONE_SIGNOFF, IDLE, PR_OPEN, AskHuman, Orchestrator, SubmitPlan, ) from nisse_dev.executor import ExecutorOutcome from nisse_dev.gitea import GiteaError, PullRequest from nisse_dev.oplog import OpLog from nisse_dev.publish import PublishResult from nisse_dev.state import StateStore from nisse_dev.transport import InMemoryTransport class Planner: def __init__(self, ask_first: bool = False) -> None: self._ask = ask_first def plan( self, gate: str, project: str, feedback: str | None = None, answer: str | None = None, ) -> SubmitPlan | AskHuman: if self._ask and answer is None: return AskHuman("which language?") return SubmitPlan(f"{gate} plan") class Board: def __init__(self, *, fail_close: bool = False) -> None: self.closed: list[tuple[int, str]] = [] self._fail_close = fail_close def open_project_issue(self, project: str, task_id: str) -> int: return 10 def post_plan(self, issue: int, gate: str, rev: int, plan: str) -> Any: class Posted: comment_id = 1 return Posted() def echo(self, issue: int, text: str) -> None: pass def close(self, issue: int, note: str) -> None: if self._fail_close: raise GiteaError("gitea is down") self.closed.append((issue, note)) class Executor: def run(self, milestone: int) -> ExecutorOutcome: return ExecutorOutcome("completed", "done") class Publisher: def __init__(self) -> None: self.closed: list[tuple[int, str]] = [] def reconcilers(self) -> dict[str, Any]: return {} def publish(self, **kwargs: Any) -> PublishResult: pr = PullRequest(number=20, state="open", head="h", url="u") return PublishResult(pr, "opened PR #20") def close_pull_request(self, index: int, note: str) -> None: self.closed.append((index, note)) class Harness: def __init__( self, tmp_path: Path, *, ask_first: bool = False, board: Board | None = None ) -> None: self.store = StateStore( tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()) ) self.transport = InMemoryTransport() self.board = board or Board() self.publisher = Publisher() Orchestrator( self.store, self.transport, Planner(ask_first), operators={"don"}, board=self.board, executor=Executor(), publisher=self.publisher, ).start() @property def state(self) -> str: state = self.store.get_state() assert state is not None return state.state def say(self, *replies: str) -> None: for reply in replies: self.transport.receive("don", reply) WAITING = [ ("arch sign-off", ["use demo"], AWAIT_ARCH_SIGNOFF), ("milestone sign-off", ["use demo", "approve"], AWAIT_MILESTONE_SIGNOFF), ( "approved, not started", ["use demo", "approve", "approve"], AWAIT_MILESTONE_SIGNOFF, ), ("PR open", ["use demo", "approve", "approve", "start milestone 1"], PR_OPEN), ] @pytest.mark.parametrize( ("replies", "waiting"), [(r, w) for _, r, w in WAITING], ids=[n for n, _, _ in WAITING], ) def test_abandon_ends_the_task_from_any_waiting_state( tmp_path: Path, replies: list[str], waiting: str ) -> None: h = Harness(tmp_path) h.say(*replies) assert h.state == waiting h.say("abandon") state = h.store.get_state() assert state.state == IDLE assert state.project is None assert "abandoned" in h.transport.outbound[-1].text assert h.board.closed == [(10, "abandoned by don")] # A new task starts cleanly afterwards. h.say("use other") assert h.state == AWAIT_ARCH_SIGNOFF def test_abandon_answers_a_question_by_ending_the_task(tmp_path: Path) -> None: h = Harness(tmp_path, ask_first=True) h.say("use demo") assert h.state == AWAIT_INPUT h.say("abandon") assert h.state == IDLE def test_abandon_in_pr_open_closes_the_pr(tmp_path: Path) -> None: h = Harness(tmp_path) h.say("use demo", "approve", "approve", "start milestone 1") h.say("abandon") assert h.publisher.closed == [(20, "abandoned by don")] def test_abandon_aborts_a_pending_publish_intent(tmp_path: Path) -> None: h = Harness(tmp_path) h.say("use demo") h.store.begin_intent("open_pr", {"branch": "b"}, cause="publish cut short") h.say("abandon") assert h.store.pending_intents() == [] def test_abandon_still_ends_the_task_when_gitea_is_down(tmp_path: Path) -> None: h = Harness(tmp_path, board=Board(fail_close=True)) h.say("use demo", "abandon") assert h.state == IDLE assert "could not close plan issue #10" in h.transport.outbound[-1].text def test_abandon_with_no_task_says_so(tmp_path: Path) -> None: h = Harness(tmp_path) h.say("abandon") assert h.state == IDLE assert "nothing to abandon" in h.transport.outbound[-1].text