Merge pull request 'A repository per test run instead of the shared sandbox' (#107) from fresh-test-repos into main
CI / checks (push) Successful in 5m30s

This commit was merged in pull request #107.
This commit is contained in:
dev-agent committed 2026-10-06 05:26:00 +00:00
commit b3bf6d5a6e
18 files changed
+378 -54

No files matched your search

+3 -9
View File
@@ -42,15 +42,6 @@ jobs:
- name: Unit tests
run: uv run pytest -m "not integration and not e2e"
- name: Push key
# The sandbox's deploy key, where nisse-test.yaml expects it.
env:
NISSE_PUSH_KEY: ${{ secrets.NISSE_PUSH_KEY }}
run: |
mkdir -p /tmp/nisse-test
printf '%s\n' "$NISSE_PUSH_KEY" > /tmp/nisse-test/push_key
chmod 600 /tmp/nisse-test/push_key
- name: Integration tests
# Everything but the secrets is in tests/integration/nisse-test.yaml,
# which refers to these by name. A missing secret fails the live
@@ -59,4 +50,7 @@ jobs:
NISSE_LLM_TOKEN: ${{ secrets.NISSE_LLM_TOKEN }}
NISSE_MATRIX_TOKEN: ${{ secrets.NISSE_MATRIX_TOKEN }}
NISSE_GITEA_TOKEN: ${{ secrets.NISSE_GITEA_TOKEN }}
# The test operator, who makes each run's repository (and its deploy
# key) and deletes it afterwards.
NISSE_OPERATOR_TOKEN: ${{ secrets.NISSE_OPERATOR_TOKEN }}
run: uv run pytest -m "integration and not e2e" -rs
+9 -3
View File
@@ -54,8 +54,14 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
- **Models by role today:** executor `qwen3.8-27b`, planner and helper
`claude-opus-5-5`, expert `claude-haiku-4-5`. Phase 2 moves to driver and helpers.
- **Bring-up** runs on this development VM (`scripts/bringup.sh`), against test
resources: the sandbox repository, the test Matrix room, a budget-capped LiteLLM
key; configuration in `/root/nisse-e2e/`.
resources: a test repository, the test Matrix room, a budget-capped LiteLLM key;
configuration in `/root/nisse-e2e/`.
- **Test repositories are made per run** by the test operator
(`nisse-testing-operator`, `NISSE_OPERATOR_TOKEN`), as an operator provides a
project's: seeded from `tests/integration/seed/`, the agent a collaborator,
`main` protected, a deploy key of its own; deleted afterwards
(`scripts/e2e-cleanup.sh` for leftovers). They replaced the shared sandbox
repository, whose state accumulated across runs.
- **A build VM for bring-up** is provided by the operator by hand until Terraform
(Phase 6); the agent reaches it over SSH with a key from `/root/nisse-e2e/ssh/`.
@@ -68,7 +74,7 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
discussion first.
- **Every change comes with tests,** through `scripts/check.sh`.
- **The end-to-end test follows each milestone** (M1.6): the real agent, real models,
the Gitea sandbox and a real build host, driven by a scripted operator. It runs
a repository of the run's own and a real build host, driven by a scripted operator. It runs
after each milestone, not on every push, since it spends model tokens. Manual
live runs are for the new loop's first runs and phase ends. Findings are filed as
issues and either fixed before moving on or deliberately deferred.
+3 -1
View File
@@ -14,7 +14,8 @@ testpaths = [
"nisse-ops/tests",
"tests",
]
addopts = "--strict-markers"
# The seed is a test project's own code, pushed to each run's repository.
addopts = "--strict-markers --ignore=tests/integration/seed"
markers = [
"integration: end-to-end tests that exercise installed entry points or external services",
"e2e: the whole agent against real services, run after each milestone (scripts/e2e.sh)",
@@ -22,6 +23,7 @@ markers = [
[tool.ruff]
target-version = "py312"
extend-exclude = ["tests/integration/seed"] # a test project's own code
[tool.ruff.lint]
extend-select = ["I"]
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Delete test repositories a live test run left behind (it died before its
# teardown): the test operator's nisse-e2e-* repositories older than a day,
# or of any age with --all. Needs NISSE_OPERATOR_TOKEN (from .env).
#
# scripts/e2e-cleanup.sh older than a day
# scripts/e2e-cleanup.sh --all every one, e.g. when nothing is running
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -f .env ]; then set -a; . ./.env; set +a; fi
exec uv run python - "$@" <<'PY'
import os, sys
from datetime import datetime, timedelta, timezone
from nisse_dev.gitea import GiteaClient
gitea = GiteaClient("https://gitea.dongar.net", os.environ["NISSE_OPERATOR_TOKEN"])
cutoff = datetime.now(timezone.utc) - (timedelta(0) if "--all" in sys.argv else timedelta(days=1))
page, gone = 1, 0
while True:
repos = gitea._request("GET", "/user/repos", params={"page": page, "limit": 50})
if not repos:
break
for repo in repos:
created = datetime.fromisoformat(repo["created_at"].replace("Z", "+00:00"))
if repo["name"].startswith("nisse-e2e-") and created < cutoff:
gitea._request("DELETE", f"/repos/{repo['full_name']}")
print("deleted", repo["full_name"])
gone += 1
page += 1
print(f"{gone} deleted")
PY
+94 -11
View File
@@ -1,10 +1,14 @@
"""Shared by the end-to-end tests: each run gets work that can't already be
done, and a base branch of its own, so runs never depend on one another or
on what earlier runs merged."""
"""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
@@ -34,18 +38,97 @@ def operator_gitea(url: str) -> GiteaClient:
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 base_branch(operator: GiteaClient, repo: str, run: str) -> Iterator[str]:
"""A branch off main for this run's PRs to target and merge into, so
nothing accumulates in main; deleted afterwards."""
name = f"e2e-base-{run}"
def fresh_repo(
operator: GiteaClient, run: str, keys: Path, *, agent: str
) -> 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",
f"/repos/{repo}/branches",
json={"new_branch_name": name, "old_branch_name": "main"},
"/user/repos",
json={
"name": name,
"private": True,
"auto_init": True,
"default_branch": "main",
},
)
try:
yield name
files = [
{
"operation": "create",
"path": str(path.relative_to(SEED)),
"content": base64.b64encode(path.read_bytes()).decode(),
}
for path in sorted(SEED.rglob("*"))
if path.is_file() and path.name != "README.md"
]
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")
operator._request(
"PUT",
f"/repos/{full}/contents/README.md",
json={
"branch": "main",
"message": "README",
"sha": readme["sha"],
"content": base64.b64encode((SEED / "README.md").read_bytes()).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/{repo}/branches/{name}")
operator._request("DELETE", f"/repos/{full}")
+6 -7
View File
@@ -13,17 +13,16 @@ state_db: /tmp/nisse-test/state.db
projects:
nisse-dev-testing:
repo: dongar/nisse-dev-testing-sandbox # the sandbox, never a real project
# The live tests replace these with a throwaway local sshd (the `ssh_host`
# fixture) or the bring-up build VM, in temporary directories.
# Placeholders: every live test that touches a repository makes one of
# its own (tests/integration/e2e_support.py, fresh_repo), owned by the
# test operator and deleted afterwards, with its own deploy key; and runs
# on a throwaway local sshd (the `ssh_host` fixture) or the bring-up build
# VM, in temporary directories.
repo: nisse-testing-operator/made-per-run
workspace: /tmp/nisse-test/work
build_host:
address: 127.0.0.1
key: /tmp/nisse-test/unused-key
# What pushes go by (D4): the sandbox's deploy key, whose private half CI
# writes to this path from the NISSE_PUSH_KEY secret (locally: copy it
# there). An HTTPS push token works too: push_user plus push_token.
push_key: /tmp/nisse-test/push_key
llm:
base_url: https://litellm.dongar.net
+4
View File
@@ -0,0 +1,4 @@
# sandbox
A small Python package for nisse-dev's end-to-end tests: each run gets a
fresh repository seeded with this. Run its tests with `python3 -m pytest`.
@@ -0,0 +1,2 @@
from .text import truncate
from .mathutils import factorial
@@ -0,0 +1,16 @@
"""Math utilities."""
def factorial(n: int) -> int:
"""Return n! for a non-negative integer n.
Raises TypeError if n is not an int (bool is rejected), ValueError if n < 0.
"""
if isinstance(n, bool) or not isinstance(n, int):
raise TypeError(f"n must be an int, got {type(n).__name__}")
if n < 0:
raise ValueError(f"n must be >= 0, got {n}")
result = 1
for i in range(2, n + 1):
result *= i
return result
+10
View File
@@ -0,0 +1,10 @@
import re
import unicodedata
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def slugify(text: str) -> str:
"""Lowercase; runs of non-alphanumerics -> single '-'; no leading/trailing '-'."""
ascii_text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
return _NON_ALNUM.sub("-", ascii_text.lower()).strip("-")
+16
View File
@@ -0,0 +1,16 @@
"""Text utilities."""
ELLIPSIS = "\u2026"
def truncate(text: str, n: int) -> str:
"""Return text unchanged if len(text) <= n, else its first n-1 chars followed by '…'.
Raises ValueError if n < 1.
"""
if n < 1:
raise ValueError(f"n must be >= 1, got {n}")
if len(text) <= n:
return text
return text[: n - 1] + ELLIPSIS
@@ -0,0 +1,42 @@
import math
import pytest
from sandbox import factorial
@pytest.mark.parametrize(
"n, expected",
[
(0, 1),
(1, 1),
(2, 2),
(3, 6),
(5, 120),
(10, 3628800),
(20, 2432902008176640000),
],
)
def test_factorial_known_values(n, expected):
assert factorial(n) == expected
def test_factorial_matches_math_factorial():
for n in range(0, 101):
assert factorial(n) == math.factorial(n)
def test_factorial_large_input_no_recursion_limit():
assert factorial(2000) == math.factorial(2000)
@pytest.mark.parametrize("n", [-1, -5, -100])
def test_factorial_negative_raises_value_error(n):
with pytest.raises(ValueError):
factorial(n)
@pytest.mark.parametrize("n", [5.0, 2.5, "5", None, True, False])
def test_factorial_non_int_raises_type_error(n):
with pytest.raises(TypeError):
factorial(n)
@@ -0,0 +1,25 @@
import pytest
from sandbox.slugify import slugify
@pytest.mark.parametrize(
"text, expected",
[
("Hello World", "hello-world"),
("Hello, World!", "hello-world"),
(" leading and trailing ", "leading-and-trailing"),
("multiple spaces", "multiple-spaces"),
("a--b__c..d", "a-b-c-d"),
("!!!", ""),
("", ""),
("-already-slugged-", "already-slugged"),
("MiXeD CaSe 123", "mixed-case-123"),
("tab\tand\nnewline", "tab-and-newline"),
("Café Déjà Vu", "cafe-deja-vu"),
("emoji 🎉 party", "emoji-party"),
("Foo Bar", "foo-bar"),
],
)
def test_slugify(text, expected):
assert slugify(text) == expected
@@ -0,0 +1,37 @@
import pytest
from sandbox import truncate
@pytest.mark.parametrize(
"text, n, expected",
[
("", 1, ""),
("abc", 3, "abc"),
("abc", 10, "abc"),
("abcd", 3, "ab\u2026"),
("hello world", 5, "hell\u2026"),
("ab", 1, "\u2026"),
("a", 1, "a"),
("h\u00e9llo w\u00f6rld", 4, "h\u00e9l\u2026"),
],
)
def test_truncate_cases(text, n, expected):
assert truncate(text, n) == expected
@pytest.mark.parametrize("n", range(1, 16))
def test_result_never_exceeds_n(n):
assert len(truncate("hello world!!", n)) <= n
def test_uses_single_ellipsis_char():
result = truncate("hello world", 5)
assert result.endswith("\u2026")
assert not result.endswith("...")
@pytest.mark.parametrize("n", [0, -5])
def test_invalid_n_raises(n):
with pytest.raises(ValueError):
truncate("abc", n)
+10 -5
View File
@@ -25,7 +25,7 @@ from pathlib import Path
from typing import Any
import pytest
from e2e_support import base_branch, operator_gitea
from e2e_support import fresh_repo, operator_gitea
from e2e_support import task as make_task
from nisse_dev.agent import Agent, build_agent
@@ -38,7 +38,7 @@ from nisse_dev.control import (
PR_OPEN,
)
from nisse_dev.execution import SSHHost
from nisse_dev.gitea import GiteaError
from nisse_dev.gitea import GiteaClient, GiteaError
from nisse_dev.transport import InMemoryTransport
pytestmark = [pytest.mark.integration, pytest.mark.e2e]
@@ -72,10 +72,15 @@ def agent(
run = uuid.uuid4().hex[:8]
stack = contextlib.ExitStack()
operator = operator_gitea(live_config.gitea_url)
base = stack.enter_context(base_branch(operator, live_config.gitea_repo, run))
account = GiteaClient(live_config.gitea_url, live_config.gitea_token).whoami()
fresh = stack.enter_context(
fresh_repo(operator, run, tmp_path / "keys", agent=account)
)
config = replace(
live_config.with_project(
base_branch=base,
repo=fresh.name,
base_branch="main",
push_key=fresh.push_key,
workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
build_host=build_host(ssh_host),
),
@@ -100,7 +105,7 @@ def agent(
state_dir=tmp_path / "ssh",
)
host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
stack.close() # the run's base branch
stack.close() # the run's repository
class Operator:
+15 -6
View File
@@ -21,7 +21,7 @@ from pathlib import Path
from typing import Any
import pytest
from e2e_support import base_branch, dependent_task, operator_gitea, task
from e2e_support import dependent_task, fresh_repo, operator_gitea, task
from nisse_dev.config import AgentConfig, LoopTuning
from nisse_dev.execution import SSHHost
@@ -63,10 +63,13 @@ def running(
keep.mkdir(parents=True, exist_ok=True)
print(f"\ne2e run {run}: state store in {keep}")
operator = operator_gitea(live_config.gitea_url)
base = stack.enter_context(base_branch(operator, live_config.gitea_repo, run))
account = GiteaClient(live_config.gitea_url, live_config.gitea_token).whoami()
fresh = stack.enter_context(fresh_repo(operator, run, keep / "keys", agent=account))
config = replace(
live_config.with_project(
base_branch=base,
repo=fresh.name,
base_branch="main",
push_key=fresh.push_key,
workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
build_host=build_host(ssh_host),
),
@@ -95,7 +98,7 @@ def running(
state_dir=tmp_path / "ssh",
)
host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
stack.close() # the run's base branch
stack.close() # the run's repository
def is_artifact(path: str) -> bool:
@@ -108,8 +111,14 @@ def is_artifact(path: str) -> bool:
def merge(gitea: GiteaClient, repo: str, pr: int, wait_s: float = 60) -> None:
"""Merge, waiting out Gitea's "Please try again later": it answers that
while it is still checking a freshly pushed PR can merge."""
"""Approve and merge, waiting out Gitea's "Please try again later": it
answers that while it is still checking a freshly pushed PR can merge."""
with contextlib.suppress(GiteaError): # approved already
gitea._request(
"POST",
f"/repos/{repo}/pulls/{pr}/reviews",
json={"event": "APPROVED", "body": "looks good"},
)
deadline = time.monotonic() + wait_s
while True:
try:
+33
View File
@@ -0,0 +1,33 @@
"""The per-run test repository (e2e_support.fresh_repo): what the operator
provides for a project (D4, D8), made and deleted by the test operator."""
import uuid
from pathlib import Path
import pytest
from e2e_support import fresh_repo, operator_gitea
from nisse_dev.config import AgentConfig
from nisse_dev.gitea import GiteaClient, GiteaError
pytestmark = pytest.mark.integration
def test_a_fresh_repository_is_seeded_shared_and_protected(
tmp_path: Path, live_config: AgentConfig, gitea: GiteaClient
) -> None:
operator = operator_gitea(live_config.gitea_url)
run = uuid.uuid4().hex[:8]
with fresh_repo(operator, run, tmp_path / "keys", agent=gitea.whoami()) as repo:
# Seeded: the package and its tests, and the README replaced.
text = gitea._request("GET", f"/repos/{repo.name}/contents/sandbox/text.py")
assert text["name"] == "text.py"
assert gitea._request("GET", f"/repos/{repo.name}/contents/tests")
# The agent sees it as a collaborator that may push, and main as
# protected against it (what D4's verification reads).
main = gitea._request("GET", f"/repos/{repo.name}/branches/main")
assert main["protected"] and not main["user_can_push"]
assert gitea._request("GET", f"/repos/{repo.name}")["permissions"]["push"]
assert repo.push_key.exists()
with pytest.raises(GiteaError, match="404"):
gitea._request("GET", f"/repos/{repo.name}")
+22 -12
View File
@@ -1,19 +1,18 @@
"""Live Gitea round trip against the sandbox repo: push a branch, open a PR,
comment, and prove the L8 reconciler reads external truth correctly.
Cleans up after itself (closes the PR, deletes the branch) so repeated runs
do not silt up the sandbox.
"""Live Gitea round trip in a repository of the run's own: push a branch,
open a PR, comment, and prove the L8 reconciler reads external truth
correctly, and that protection holds against the agent (D4).
"""
import uuid
from pathlib import Path
import pytest
from e2e_support import fresh_repo, operator_gitea
from nisse_dev.agent import push_credential, remote_url
from nisse_dev.agent import remote_url
from nisse_dev.config import AgentConfig
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitError, GitRepo
from nisse_dev.git import GitError, GitRepo, SSHKey
from nisse_dev.gitea import GiteaClient, pull_request_reconciler
from nisse_dev.state import Intent
@@ -34,9 +33,21 @@ def intent_for(branch: str) -> Intent:
def test_push_open_pr_and_reconcile(
tmp_path: Path, live_config: AgentConfig, gitea: GiteaClient, ssh_host: SSHHost
) -> None:
repo_path = live_config.gitea_repo # the sandbox
client = gitea
with fresh_repo(
operator_gitea(live_config.gitea_url),
uuid.uuid4().hex[:8],
tmp_path / "keys",
agent=client.whoami(),
) as fresh:
_round_trip(
tmp_path, client, ssh_host, fresh.name, SSHKey(fresh.push_key.read_text())
)
def _round_trip(
tmp_path: Path, client: GiteaClient, ssh_host: SSHHost, repo_path: str, key: SSHKey
) -> None:
account = client.whoami()
assert "testing" in account, (
f"live tests must run as a testing account, not {account}"
@@ -49,12 +60,12 @@ def test_push_open_pr_and_reconcile(
assert reconcile(intent_for(branch)) is False
# Pushed the way the agent does: from the build VM (here the throwaway
# sshd), with the push credential the config names, never the API token.
key = push_credential(live_config)
assert key is not None, "nisse-test.yaml names no push credential (D4)"
# sshd), with the repository's deploy key, never the API token.
url = remote_url(client, repo_path, key)
repo = GitRepo(ssh_host, tmp_path / "work", known_hosts=str(tmp_path / "kh"))
repo.init()
repo._git("fetch", "-q", url, "main", credential=key)
repo._git("checkout", "-q", "-B", "work", "FETCH_HEAD")
repo.configure_identity(account, f"{account}@example.test")
(repo.root / f"{branch}.txt").write_text("live test artifact\n")
assert repo.commit_all("Live test commit") is not None
@@ -93,5 +104,4 @@ def test_push_open_pr_and_reconcile(
finally:
if pr is not None:
client.close_pull_request(repo_path, pr.number)
repo.delete_remote_branch(url, branch, credential=key)
client.close()