import io import subprocess from pathlib import Path from typing import Any import pytest from nisse_dev.execution import SSHHost 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 # The build VM for every test here: the session's throwaway sshd. HOST: SSHHost @pytest.fixture(autouse=True) def _build_host(ssh_host: SSHHost) -> None: global HOST HOST = ssh_host class FakeGitea: def __init__(self, *, fail: bool = False) -> None: self.opened: list[dict[str, Any]] = [] self.existing: list[str] = [] self.closed: set[str] = set() 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: state = "closed" if head in self.closed else "open" return PullRequest(number=1, state=state, head=head, url="u") return None class FakeGit: """A real repository whose origin/main is a local ref; pushes are recorded, not sent.""" def __init__( self, root: Path, *, changes: bool = True, committed: bool = False ) -> None: self.repo = GitRepo(HOST, root) self.repo.init() self.repo.configure_identity("bot", "bot@example.test") (root / "README.md").write_text("sandbox") self.repo.commit_all("Initial commit") git(root, "update-ref", "refs/remotes/origin/main", "HEAD") if changes: (root / "work.txt").write_text("done") if committed: # The executor committing its own work, as on the first live run. self.repo.commit_all("Add the work") self.pushed: list[str] = [] self.remote: dict[str, str] = {} def head_sha(self) -> str: return self.repo.head_sha() def commits_since(self, ref: str) -> int: return self.repo.commits_since(ref) def remote_sha( self, remote_url: str, branch: str, *, key: str | None = None ) -> str | None: return self.remote.get(branch) 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, *, key: str | None = None) -> None: self.pushed.append(branch) self.remote[branch] = self.repo.head_sha() def git(root: Path, *args: str) -> None: subprocess.run(["git", *args], cwd=root, check=True, capture_output=True) @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, remote_url="ssh://git@gitea.test/o/sandbox.git", push_key="KEY", ) 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 def test_work_the_executor_committed_itself_is_published( store: StateStore, tmp_path: Path ) -> None: git = FakeGit(tmp_path / "work", committed=True) client = FakeGitea() result = build(store, git, client).publish( task_id="t", milestone=1, project="demo", plan="p" ) assert result.pull_request is not None assert git.pushed == ["t/milestone-1"] assert git.remote["t/milestone-1"] == git.head_sha() def test_a_followup_the_executor_committed_itself_is_pushed( store: StateStore, tmp_path: Path ) -> None: git = FakeGit(tmp_path / "work") publisher = build(store, git, FakeGitea()) publisher.publish(task_id="t", milestone=1, project="demo", plan="p") (tmp_path / "work" / "work.txt").write_text("fixed") git.repo.commit_all("Address review") sha = publisher.push_followup(task_id="t", milestone=1, message="m") assert sha == git.head_sha() assert git.pushed == ["t/milestone-1", "t/milestone-1"] # Nothing new since that push: no second push, and it says so. assert publisher.push_followup(task_id="t", milestone=1, message="m") is None assert len(git.pushed) == 2 def test_publishing_again_adopts_the_open_pr_instead_of_opening_another( store: StateStore, tmp_path: Path ) -> None: # E.g. Gitea opened the PR but the response was lost, and the milestone # was published again: the PR is adopted and updated, never duplicated. git = FakeGit(tmp_path / "work") client = FakeGitea() publisher = build(store, git, client) publisher.publish(task_id="t", milestone=1, project="demo", plan="p") (tmp_path / "work" / "more.txt").write_text("more") result = publisher.publish(task_id="t", milestone=1, project="demo", plan="p") assert len(client.opened) == 1 assert result.pull_request is not None and result.pull_request.number == 1 assert "already open" in result.note assert git.pushed == ["t/milestone-1", "t/milestone-1"] # updated it assert store.pending_intents() == [] def test_a_closed_pr_is_not_adopted(store: StateStore, tmp_path: Path) -> None: git = FakeGit(tmp_path / "work") client = FakeGitea() client.existing.append("t/milestone-1") client.closed.add("t/milestone-1") publisher = build(store, git, client) assert publisher.published_pull_request("t", 1) is None publisher.publish(task_id="t", milestone=1, project="demo", plan="p") assert len(client.opened) == 1 # the operator closed it; a new one opens