"""Several projects on the new loop (M1.4, D8): intake from each project's repository, each item working in its own project's workspace and publishing to its own repository, issue numbers told apart, label defaults, and parking across projects. Real git on the throwaway build host; fake Gitea per repo.""" import io from pathlib import Path from typing import Any import pytest from test_loop_milestones import DON, Forge, ScriptedModel, call, git, make_world from nisse_core.conversation.transport import InMemoryTransport from nisse_core.execution.ssh import SSHHost from nisse_core.loop.driver import Driver from nisse_core.oplog import OpLog from nisse_core.state.cancel import Cancel from nisse_core.state.worker import InlineWorker from nisse_dev.loop.agent import LoopAgent, Project from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, item_for_issue, loop_of from nisse_dev.tools import Workspace REPOS = {"alpha": "don/alpha", "beta": "don/beta"} class Router: """One Gitea for the agent, routing each call to its repository's fake.""" def __init__(self, forges: dict[str, Forge]) -> None: self.forges = forges def __getattr__(self, name: str) -> Any: def call(repo: str, *args: Any, **kwargs: Any) -> Any: return getattr(self.forges[repo], name)(repo, *args, **kwargs) return call @pytest.fixture def worlds(ssh_host: SSHHost, tmp_path: Path) -> dict[str, dict[str, Any]]: alpha = make_world(ssh_host, tmp_path / "alpha", repo_name=REPOS["alpha"]) beta = make_world( ssh_host, tmp_path / "beta", store=alpha["store"], repo_name=REPOS["beta"] ) return {"alpha": alpha, "beta": beta} def agent_for( worlds: dict[str, dict[str, Any]], model: ScriptedModel, labels: dict[str, tuple[str, ...]] | None = None, ) -> tuple[LoopAgent, InMemoryTransport]: transport = InMemoryTransport() cancel = Cancel() router = Router({REPOS[n]: w["forge"] for n, w in worlds.items()}) projects = [ Project( name=n, repo=REPOS[n], workspace=Workspace(w["host"], w["repo"].root, timeout_s=30), milestones=w["milestones"], labels=(labels or {}).get(n, ()), ) for n, w in worlds.items() ] for w in worlds.values(): w["milestones"]._gitea = router # publishing goes through the router too agent = LoopAgent( worlds["alpha"]["store"], transport, router, # type: ignore[arg-type] projects=projects, account="nisse-dev", operators={DON}, gitea_operators={"don"}, driver=Driver(model, "m", cancel=cancel), # type: ignore[arg-type] worker=InlineWorker(), cancel=cancel, oplog=OpLog("nisse-dev", io.StringIO()), ) transport.on_message(agent.on_message) return agent, transport def item(worlds: dict[str, Any], project: str) -> Any: found = item_for_issue(worlds["alpha"]["store"], 14, project) assert found is not None return found def say(transport: InMemoryTransport, worlds: Any, project: str, text: str) -> None: transport.receive(DON, text, thread=loop_of(item(worlds, project)).thread) def restate(name: str) -> list[Any]: return [call("restate", text=f"{name}: add count_words")] def work(path: str) -> list[Any]: return [ call("start_milestone", name=path), call("write_file", path=path, content=f"# {path}\n"), call("finish_milestone", summary=path, final="yes"), ] def test_both_projects_issues_are_taken_and_told_apart(worlds: Any) -> None: # Both forges have an issue #14. agent, transport = agent_for(worlds, ScriptedModel(restate("a"), restate("b"))) agent.intake() a, b = item(worlds, "alpha"), item(worlds, "beta") assert (a.scope, b.scope) == ("alpha", "beta") said = [m.text for m in transport.outbound] assert any(t.startswith("Queued don/alpha#14") for t in said) assert any(t.startswith("Queued don/beta#14") for t in said) status = agent.status() assert "alpha#14 Add count_words" in status and "beta#14 Add count_words" in status def test_each_item_works_and_publishes_in_its_own_project(worlds: Any) -> None: model = ScriptedModel(restate("a"), restate("b"), work("a.py"), work("b.py")) agent, transport = agent_for(worlds, model) agent.intake() say(transport, worlds, "alpha", "go") say(transport, worlds, "beta", "go") fa, fb = worlds["alpha"]["forge"], worlds["beta"]["forge"] [pa], [pb] = fa.prs, fb.prs assert fa.files(pa) == ["a.py"] and fb.files(pb) == ["b.py"] assert fa.prs[pa]["head"].startswith(item(worlds, "alpha").id) assert fb.prs[pb]["head"].startswith(item(worlds, "beta").id) # Each workspace has only its own project's work. assert not (worlds["alpha"]["repo"].root / "b.py").exists() assert loop_of(item(worlds, "beta")).wait == MERGE def test_a_bare_number_in_two_projects_is_asked_about(worlds: Any) -> None: model = ScriptedModel(restate("a"), restate("b"), work("b.py")) agent, transport = agent_for(worlds, model) agent.intake() transport.receive(DON, "go #14") asked = transport.outbound[-1].text assert "open in more than one project" in asked assert "go alpha#14" in asked and "go beta#14" in asked assert not loop_of(item(worlds, "alpha")).started transport.receive(DON, "go beta#14") assert loop_of(item(worlds, "beta")).started assert not loop_of(item(worlds, "alpha")).started def test_do_next_names_a_project(worlds: Any) -> None: agent, transport = agent_for(worlds, ScriptedModel(restate("a"), restate("b"))) agent.intake() transport.receive(DON, "do #14 next") assert "do alpha#14 next or do beta#14 next" in transport.outbound[-1].text transport.receive(DON, "do beta#14 next") assert "beta#14 goes first" in transport.outbound[-1].text def test_a_projects_label_defaults_steer_its_items(worlds: Any) -> None: model = ScriptedModel( [call("ask_human", question="what's it for?")], # alpha: discuss first restate("b"), ) agent, _ = agent_for(worlds, model, labels={"alpha": ("discuss",)}) agent.intake() assert loop_of(item(worlds, "alpha")).labels == ("discuss",) assert loop_of(item(worlds, "alpha")).wait == ANSWER assert loop_of(item(worlds, "beta")).wait == GO_AHEAD def test_switching_projects_parks_the_other_projects_work(worlds: Any) -> None: model = ScriptedModel( restate("a"), restate("b"), [ # alpha: half done, then a question call("start_milestone", name="a"), call("write_file", path="half.py", content="H = 1\n"), call("ask_human", question="one more thing?"), ], work("b.py"), # beta runs meanwhile ) agent, transport = agent_for(worlds, model) agent.intake() say(transport, worlds, "alpha", "go") say(transport, worlds, "beta", "go") bare = ["--git-dir", str(worlds["alpha"]["bare"])] parked = f"refs/heads/{item(worlds, 'alpha').id}-parked" assert git(*bare, "show", f"{parked}^3:half.py") == "H = 1"