CI / checks (push) Skipped
CI / checks (pull_request) Failing after 56s
The clone gets the standard build artifacts in its own exclude file, so they never show as changes, even to a model's `git add -A`. The harness's commits leave the same patterns out even when the project tracks them, as the sandbox's main did. What was left out is reported in the publish note and the PR description. Found again in bring-up run 3 (sandbox PR #208). Fixes #80. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
290 lines
10 KiB
Python
290 lines
10 KiB
Python
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, HTTPSToken
|
|
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, *, credential: object = 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 left_out(self) -> list[str]:
|
|
return self.repo.left_out()
|
|
|
|
def push(self, remote_url: str, branch: str, *, credential: object = 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_credential=HTTPSToken("pusher", "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_a_branch_named_after_the_task_does_not_block_publishing(
|
|
store: StateStore, tmp_path: Path
|
|
) -> None:
|
|
# Live run 3: the executor ran `git checkout -b <task id>` itself. With
|
|
# "<task>/milestone-1" as the harness's branch, git refused it, since a
|
|
# branch name can't also be a directory of other branch names.
|
|
git = FakeGit(tmp_path / "work")
|
|
git.repo.checkout_new_branch("demo-abc")
|
|
result = build(store, git, FakeGitea()).publish(
|
|
task_id="demo-abc", milestone=1, project="demo", plan="p"
|
|
)
|
|
assert result.pull_request is not None
|
|
assert git.pushed == ["demo-abc-milestone-1"]
|
|
|
|
|
|
def test_left_out_artifacts_are_reported_not_published(
|
|
store: StateStore, tmp_path: Path
|
|
) -> None:
|
|
git = FakeGit(tmp_path / "work")
|
|
(tmp_path / "work" / "__pycache__").mkdir()
|
|
(tmp_path / "work" / "__pycache__" / "work.cpython-310.pyc").write_bytes(b"x")
|
|
client = FakeGitea()
|
|
result = build(store, git, client).publish(
|
|
task_id="t", milestone=1, project="demo", plan="p"
|
|
)
|
|
assert "left out 1 build artifact(s): __pycache__/work.cpython-310.pyc" in (
|
|
result.note
|
|
)
|
|
assert "## Left out" in client.opened[0]["body"]
|
|
assert "`__pycache__/work.cpython-310.pyc`" in client.opened[0]["body"]
|
|
|
|
|
|
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
|