Projects as configuration entries (M1.4, D8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
- Config: `projects: {name: entry}` with repo, base_branch, workspace,
build_host, push credential, labels (defaults added to every issue),
secrets by ${VAR} reference with visibility (commands | harness), and
egress (recorded, not enforced). Every problem in every entry is
reported at once; an inline secret, a bad visibility, a repeated repo or
a non-slug name is an error.
- The old loop runs exactly one project (only_project) and refuses more.
- The new loop is per project: each its own clone, workspace (with its
commands-visible secrets, redacted on return), push credential and
milestones; intake polls every project's repository; items are scoped
to their project for every run, publish and PR watch. Issues are named
project#14 when there is more than one project, and a bare #14 that
matches two is asked about. Switching projects parks the one worked on
last (all others after a boot).
- A restatement that ends in a question is sent back to the driver, not
posted: the e2e driver kept asking "Is this the right interpretation?",
which a plain yes can't answer.
Tests: config entries and errors, two-project assembly, two-project loop
tests (intake, own workspaces and repos, #14 in both, do-next, label
defaults, parking across projects), the property walk over two projects
with a new own_repository_only property (an item using the first
project's repo fails 15 of 16 seeds), and the restatement check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
4ecff19933
commit
064d9bf554
17 files changed
+1139
-315
No files matched your search
@@ -0,0 +1,188 @@
|
||||
"""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_dev.cancel import Cancel
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, item_for_issue, loop_of
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
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"
|
||||
Reference in new issue
Block a user