Files
nisse/nisse-dev/tests/test_abandon.py
T
dongarandClaude Opus 5.5 1ad0b2147d
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Retire the old build spec, and point every reference at the architecture (M0.1)
Development Plan M0.1. Every section reference in code, tests, specs/,
the example config, the tuning guide, and the CLAUDE.md and README files
now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old
spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps
it). The plan's old-to-new table goes with it.

Mostly mechanical, with a per-site choice where one old section split:
A4 became L2 where it meant stop/abandon/the loop and L3 where it meant
approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2
became D4 for outbound Gitea and publishing and D5 for review intake;
'A3 step N' became the matching step of L8's boot sequence. Comments
describing behaviour the architecture drops (planning always escalating,
thresholds tuned at an old build step) now describe the current code and
point at the Development Plan.

docs/Architecture.md gains one line the rewrite missed: intake outages
delay but never fail work (old B2), now in L5, where the poller and
Matrix code cite it.

Comments and docs only; no behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-01 00:17:48 +00:00

186 lines
5.2 KiB
Python

"""The `abandon` command: ending a task from any waiting state (L2)."""
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