import io from pathlib import Path from typing import Any import pytest from nisse_dev.git import GitRepo from nisse_dev.gitea import GiteaError, PullRequest from nisse_dev.oplog import OpLog from nisse_dev.publish import OPEN_PR_INTENT, MilestonePublisher from nisse_dev.state import StateStore class FakeGitea: def __init__(self, *, fail: bool = False) -> None: self.opened: list[dict[str, Any]] = [] self.existing: list[str] = [] self._fail = fail def open_pull_request( self, repo: str, *, head: str, base: str, title: str, body: str ) -> PullRequest: if self._fail: raise GiteaError("gitea is down") self.opened.append( {"repo": repo, "head": head, "base": base, "title": title, "body": body} ) self.existing.append(head) return PullRequest( number=len(self.opened), state="open", head=head, url=f"u/{head}" ) def find_pull_request(self, repo: str, head: str) -> PullRequest | None: if head in self.existing: return PullRequest(number=1, state="open", head=head, url="u") return None class FakeGit: """Records what would have been pushed; no network.""" def __init__(self, root: Path, *, changes: bool = True) -> None: self.repo = GitRepo(root) self.repo.init() self.repo.configure_identity("bot", "bot@example.test") if changes: (root / "work.txt").write_text("done") self.pushed: list[str] = [] def current_branch(self) -> str: return self.repo.current_branch() def checkout_new_branch(self, name: str) -> None: self.repo.checkout_new_branch(name) def commit_all(self, message: str) -> str | None: return self.repo.commit_all(message) def push(self, remote_url: str, branch: str, *, token: str | None = None) -> None: self.pushed.append(branch) @pytest.fixture def store(tmp_path: Path) -> StateStore: return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO())) def build(store: StateStore, git: Any, client: Any) -> MilestonePublisher: return MilestonePublisher( store, client, "o/sandbox", git, base_url="https://gitea.test", account="bot", token="tok", ) def test_publish_commits_pushes_and_opens_a_pr( store: StateStore, tmp_path: Path ) -> None: git = FakeGit(tmp_path / "work") client = FakeGitea() result = build(store, git, client).publish( task_id="demo-abc", milestone=2, project="demo", plan="the plan" ) assert git.pushed == ["demo-abc/milestone-2"] assert client.opened[0]["head"] == "demo-abc/milestone-2" assert client.opened[0]["base"] == "main" assert "the plan" in client.opened[0]["body"] assert result.pull_request is not None assert "opened PR #1" in result.note def test_intent_is_recorded_then_committed(store: StateStore, tmp_path: Path) -> None: build(store, FakeGit(tmp_path / "work"), FakeGitea()).publish( task_id="t", milestone=1, project="demo", plan="p" ) # Nothing left pending: the intent opened before the push was settled # once the PR existed. assert store.pending_intents() == [] def test_failure_after_intent_leaves_it_pending_for_reconciliation( store: StateStore, tmp_path: Path ) -> None: publisher = build(store, FakeGit(tmp_path / "work"), FakeGitea(fail=True)) with pytest.raises(GiteaError): publisher.publish(task_id="t", milestone=1, project="demo", plan="p") [pending] = store.pending_intents() assert pending.kind == OPEN_PR_INTENT assert pending.payload["branch"] == "t/milestone-1" assert pending.payload["sha"] def test_nothing_to_commit_is_reported_not_raised( store: StateStore, tmp_path: Path ) -> None: git = FakeGit(tmp_path / "work", changes=False) client = FakeGitea() result = build(store, git, client).publish( task_id="t", milestone=1, project="demo", plan="p" ) assert result.pull_request is None assert result.note == "no changes to publish" assert client.opened == [] # No externally visible act was attempted, so no intent was opened. assert store.pending_intents() == [] def test_branch_is_namespaced_by_task(store: StateStore, tmp_path: Path) -> None: publisher = build(store, FakeGit(tmp_path / "work"), FakeGitea()) assert publisher.branch_for("greet-9f2", 3) == "greet-9f2/milestone-3" def test_reconciler_settles_an_intent_left_by_a_crash( store: StateStore, tmp_path: Path ) -> None: # Simulate the dangerous window: the PR was opened, but the process died # before the intent could be committed. On the next boot the reconciler # must say "already done" so the milestone is not published twice. client = FakeGitea() publisher = build(store, FakeGit(tmp_path / "work"), client) intent_id = store.begin_intent( OPEN_PR_INTENT, {"branch": "t/milestone-1"}, cause="crash simulation" ) client.existing.append("t/milestone-1") reconcilers = publisher.reconcilers() assert set(reconcilers) == {OPEN_PR_INTENT} [pending] = store.pending_intents() assert reconcilers[OPEN_PR_INTENT](pending) is True store.commit_intent(intent_id, cause="verified") assert client.opened == [] # nothing re-published