- propose_project(name, purpose, decisions) in a conversation goes up for a go-ahead (checked: a slug, not an existing project, no trailing question). - On go, the provisioning checklist (D1): a repository with a README, protection on its default branch, the agent as a collaborator, a build VM, a deploy key, and the project entry as YAML with <...> to fill in. - On "done", the agent reads its configuration again and adds the new project live (build_loop_agent(load=...); only additions), then checks what was provided (D4, verified, not assumed): permissions and protection as it sees them, then on a throwaway branch with an empty commit, a branch push must succeed and a push to the default branch and merging its own PR must be refused. The check's PR is closed and its branch deleted. Failures are said per item; nothing starts. - Checked: the tracking issue (the distilled decisions, carrying the new item's ID), and the project's item, agreed and labelled `design`, whose first milestone is ARCHITECTURE.md and the build plan. A conversation waiting on provisioning doesn't close as idle. - The walk replays exactly from its seed (item IDs too), and event IDs stay unique across simulated restarts, as a server's are. - The test config holds output to 8192 tokens, under the test key's per-request cost cap. Tests: new-project unit tests (checklist and its YAML parse as config, done before the entry, what isn't set up, a checked project's record and first PR, an existing name), protection enforced by a hook on the fake forge, the walk proposing projects and saying done, and an e2e new project in a repository the test operator provisions from the agent's checklist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
153 lines
5.2 KiB
Python
153 lines
5.2 KiB
Python
"""Shared by the live tests: each run gets a repository of its own, made by
|
|
the test operator and deleted afterwards, so runs never depend on one
|
|
another or on what earlier runs merged."""
|
|
|
|
import base64
|
|
import contextlib
|
|
import os
|
|
import subprocess
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from nisse_dev.gitea import GiteaClient, GiteaError
|
|
from nisse_dev.llm import RoleMap
|
|
|
|
# The end-to-end tests run every role on a local model, which costs nothing
|
|
# per call; NISSE_E2E_MODEL overrides it (e.g. claude-haiku-4-5 for a quick,
|
|
# paid run).
|
|
E2E_MODEL = "qwen3.8-27b"
|
|
|
|
|
|
def e2e_roles() -> RoleMap:
|
|
model = os.environ.get("NISSE_E2E_MODEL", "").strip() or E2E_MODEL
|
|
return RoleMap(executor=model, planner=model, helper=model, expert=model)
|
|
|
|
|
|
def task(run: str) -> str:
|
|
"""A request no earlier run can have fulfilled."""
|
|
return (
|
|
f"Add a function vowels_{run}(text) to the sandbox package that returns "
|
|
"how many of a, e, i, o, u (either case) the text contains, with tests."
|
|
)
|
|
|
|
|
|
def dependent_task(run: str) -> str:
|
|
"""A request that builds on task(run): it can only be done after it."""
|
|
return (
|
|
f"Add a function consonants_{run}(text) to the sandbox package that "
|
|
"returns how many letters in the text are not vowels, using "
|
|
f"vowels_{run}, with tests."
|
|
)
|
|
|
|
|
|
def operator_gitea(url: str) -> GiteaClient:
|
|
token = os.environ.get("NISSE_OPERATOR_TOKEN", "")
|
|
assert token.strip(), (
|
|
"NISSE_OPERATOR_TOKEN is not set: the test operator's Gitea token"
|
|
)
|
|
return GiteaClient(url, token)
|
|
|
|
|
|
OWNER = "nisse-testing-operator" # the test operator: owns every test repository
|
|
SEED = Path(__file__).parent / "seed"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FreshRepo:
|
|
name: str # owner/repo
|
|
push_key: Path # this repository's deploy key, private half
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def fresh_repo(
|
|
operator: GiteaClient, run: str, keys: Path, *, agent: str, seed: bool = True
|
|
) -> Iterator[FreshRepo]:
|
|
"""A repository of its own for one test run, as the operator would make a
|
|
project's (D4, D8): seeded with a small Python package, the ``agent``
|
|
account a collaborator with write access, the default branch protected (pushes only by the
|
|
owner, an approval to merge), and a deploy key of its own, written under
|
|
``keys``. Deleted afterwards, unless NISSE_E2E_KEEP_REPOS is set."""
|
|
name = f"nisse-e2e-{run}"
|
|
full = f"{OWNER}/{name}"
|
|
operator._request(
|
|
"POST",
|
|
"/user/repos",
|
|
json={
|
|
"name": name,
|
|
"private": True,
|
|
"auto_init": True,
|
|
"default_branch": "main",
|
|
},
|
|
)
|
|
try:
|
|
files = [
|
|
{
|
|
"operation": "create",
|
|
"path": str(path.relative_to(SEED)),
|
|
"content": base64.b64encode(path.read_bytes()).decode(),
|
|
}
|
|
for path in sorted(SEED.rglob("*"))
|
|
if seed and path.is_file() and path.name != "README.md"
|
|
]
|
|
if files:
|
|
operator._request(
|
|
"POST",
|
|
f"/repos/{full}/contents",
|
|
json={
|
|
"branch": "main",
|
|
"message": "Seed the test project",
|
|
"files": files,
|
|
},
|
|
)
|
|
readme = operator._request("GET", f"/repos/{full}/contents/README.md")
|
|
# A new project's README says only what it is; a seeded one, the seed's.
|
|
text = (SEED / "README.md").read_bytes() if seed else f"# {name}\n".encode()
|
|
operator._request(
|
|
"PUT",
|
|
f"/repos/{full}/contents/README.md",
|
|
json={
|
|
"branch": "main",
|
|
"message": "README",
|
|
"sha": readme["sha"],
|
|
"content": base64.b64encode(text).decode(),
|
|
},
|
|
)
|
|
operator._request(
|
|
"PUT", f"/repos/{full}/collaborators/{agent}", json={"permission": "write"}
|
|
)
|
|
keys.mkdir(parents=True, exist_ok=True)
|
|
key = keys / f"{name}.key"
|
|
key.unlink(missing_ok=True)
|
|
subprocess.run(
|
|
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", str(key)],
|
|
check=True,
|
|
)
|
|
operator._request(
|
|
"POST",
|
|
f"/repos/{full}/keys",
|
|
json={
|
|
"title": f"{name} push",
|
|
"key": key.with_suffix(".key.pub").read_text().strip(),
|
|
"read_only": False,
|
|
},
|
|
)
|
|
operator._request(
|
|
"POST",
|
|
f"/repos/{full}/branch_protections",
|
|
# Pushes only by the operator (who sets up fixtures, as an owner
|
|
# can); the agent's are refused, and merging needs an approval.
|
|
json={
|
|
"branch_name": "main",
|
|
"enable_push": True,
|
|
"enable_push_whitelist": True,
|
|
"push_whitelist_usernames": [OWNER],
|
|
"required_approvals": 1,
|
|
},
|
|
)
|
|
yield FreshRepo(full, key)
|
|
finally:
|
|
if not os.environ.get("NISSE_E2E_KEEP_REPOS"):
|
|
with contextlib.suppress(GiteaError):
|
|
operator._request("DELETE", f"/repos/{full}")
|