CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
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
186 lines
5.2 KiB
Python
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
|