A repository per test run instead of the shared sandbox
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 5m17s

Every live test that touches a repository gets one of its own from
e2e_support.fresh_repo, made by the test operator as an operator makes a
project's (D4, D8): seeded from tests/integration/seed/ (the sandbox's
package, without past runs' leftovers), the agent's account a collaborator
with write access, main protected (pushes only by the owner, an approval to
merge), and a deploy key generated for the run. It is deleted afterwards
(NISSE_E2E_KEEP_REPOS keeps it); scripts/e2e-cleanup.sh deletes leftovers.

- Both e2e fixtures use it; per-run base branches are gone. The merge
  helper approves first, as the protection now requires.
- test_gitea_live uses it, so CI needs NISSE_OPERATOR_TOKEN and no longer
  writes the sandbox deploy key (NISSE_PUSH_KEY).
- A live test of the helper: seeded, shared with the agent, protected
  against it, deleted.
- nisse-test.yaml's project entry is a placeholder; pytest and ruff skip
  the seed.

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:
dongarandClaude Opus 5.5 committed 2026-10-06 05:10:27 +00:00
1 parent 6c962e1ed9
commit 679ace1fd8
18 files changed
+379 -55

No files matched your search

+3 -9
View File
@@ -42,15 +42,6 @@ jobs:
- name: Unit tests - name: Unit tests
run: uv run pytest -m "not integration and not e2e" 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 - name: Integration tests
# Everything but the secrets is in tests/integration/nisse-test.yaml, # Everything but the secrets is in tests/integration/nisse-test.yaml,
# which refers to these by name. A missing secret fails the live # 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_LLM_TOKEN: ${{ secrets.NISSE_LLM_TOKEN }}
NISSE_MATRIX_TOKEN: ${{ secrets.NISSE_MATRIX_TOKEN }} NISSE_MATRIX_TOKEN: ${{ secrets.NISSE_MATRIX_TOKEN }}
NISSE_GITEA_TOKEN: ${{ secrets.NISSE_GITEA_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 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 - **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. `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 - **Bring-up** runs on this development VM (`scripts/bringup.sh`), against test
resources: the sandbox repository, the test Matrix room, a budget-capped LiteLLM resources: a test repository, the test Matrix room, a budget-capped LiteLLM key;
key; configuration in `/root/nisse-e2e/`. 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 - **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/`. (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. discussion first.
- **Every change comes with tests,** through `scripts/check.sh`. - **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 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 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 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. issues and either fixed before moving on or deliberately deferred.
+3 -1
View File
@@ -14,7 +14,8 @@ testpaths = [
"nisse-ops/tests", "nisse-ops/tests",
"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 = [ markers = [
"integration: end-to-end tests that exercise installed entry points or external services", "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)", "e2e: the whole agent against real services, run after each milestone (scripts/e2e.sh)",
@@ -22,6 +23,7 @@ markers = [
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py312"
extend-exclude = ["tests/integration/seed"] # a test project's own code
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["I"] 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
+95 -12
View File
@@ -1,10 +1,14 @@
"""Shared by the end-to-end tests: each run gets work that can't already be """Shared by the live tests: each run gets a repository of its own, made by
done, and a base branch of its own, so runs never depend on one another or the test operator and deleted afterwards, so runs never depend on one
on what earlier runs merged.""" another or on what earlier runs merged."""
import base64
import contextlib import contextlib
import os import os
import subprocess
from collections.abc import Iterator from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from nisse_dev.gitea import GiteaClient, GiteaError from nisse_dev.gitea import GiteaClient, GiteaError
@@ -34,18 +38,97 @@ def operator_gitea(url: str) -> GiteaClient:
return GiteaClient(url, 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 @contextlib.contextmanager
def base_branch(operator: GiteaClient, repo: str, run: str) -> Iterator[str]: def fresh_repo(
"""A branch off main for this run's PRs to target and merge into, so operator: GiteaClient, run: str, keys: Path, *, agent: str
nothing accumulates in main; deleted afterwards.""" ) -> Iterator[FreshRepo]:
name = f"e2e-base-{run}" """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( operator._request(
"POST", "POST",
f"/repos/{repo}/branches", "/user/repos",
json={"new_branch_name": name, "old_branch_name": "main"}, json={
"name": name,
"private": True,
"auto_init": True,
"default_branch": "main",
},
) )
try: 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: finally:
with contextlib.suppress(GiteaError): if not os.environ.get("NISSE_E2E_KEEP_REPOS"):
operator._request("DELETE", f"/repos/{repo}/branches/{name}") with contextlib.suppress(GiteaError):
operator._request("DELETE", f"/repos/{full}")
+6 -7
View File
@@ -13,17 +13,16 @@ state_db: /tmp/nisse-test/state.db
projects: projects:
nisse-dev-testing: nisse-dev-testing:
repo: dongar/nisse-dev-testing-sandbox # the sandbox, never a real project # Placeholders: every live test that touches a repository makes one of
# The live tests replace these with a throwaway local sshd (the `ssh_host` # its own (tests/integration/e2e_support.py, fresh_repo), owned by the
# fixture) or the bring-up build VM, in temporary directories. # 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 workspace: /tmp/nisse-test/work
build_host: build_host:
address: 127.0.0.1 address: 127.0.0.1
key: /tmp/nisse-test/unused-key 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: llm:
base_url: https://litellm.dongar.net 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 from typing import Any
import pytest 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 e2e_support import task as make_task
from nisse_dev.agent import Agent, build_agent from nisse_dev.agent import Agent, build_agent
@@ -38,7 +38,7 @@ from nisse_dev.control import (
PR_OPEN, PR_OPEN,
) )
from nisse_dev.execution import SSHHost 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 from nisse_dev.transport import InMemoryTransport
pytestmark = [pytest.mark.integration, pytest.mark.e2e] pytestmark = [pytest.mark.integration, pytest.mark.e2e]
@@ -72,10 +72,15 @@ def agent(
run = uuid.uuid4().hex[:8] run = uuid.uuid4().hex[:8]
stack = contextlib.ExitStack() stack = contextlib.ExitStack()
operator = operator_gitea(live_config.gitea_url) 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( config = replace(
live_config.with_project( 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"), workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
build_host=build_host(ssh_host), build_host=build_host(ssh_host),
), ),
@@ -100,7 +105,7 @@ def agent(
state_dir=tmp_path / "ssh", state_dir=tmp_path / "ssh",
) )
host.execute(f"rm -rf /tmp/nisse-e2e-{run}") host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
stack.close() # the run's base branch stack.close() # the run's repository
class Operator: class Operator:
+15 -6
View File
@@ -21,7 +21,7 @@ from pathlib import Path
from typing import Any from typing import Any
import pytest 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.config import AgentConfig, LoopTuning
from nisse_dev.execution import SSHHost from nisse_dev.execution import SSHHost
@@ -63,10 +63,13 @@ def running(
keep.mkdir(parents=True, exist_ok=True) keep.mkdir(parents=True, exist_ok=True)
print(f"\ne2e run {run}: state store in {keep}") print(f"\ne2e run {run}: state store in {keep}")
operator = operator_gitea(live_config.gitea_url) 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( config = replace(
live_config.with_project( 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"), workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
build_host=build_host(ssh_host), build_host=build_host(ssh_host),
), ),
@@ -95,7 +98,7 @@ def running(
state_dir=tmp_path / "ssh", state_dir=tmp_path / "ssh",
) )
host.execute(f"rm -rf /tmp/nisse-e2e-{run}") 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: 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: 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 """Approve and merge, waiting out Gitea's "Please try again later": it
while it is still checking a freshly pushed PR can merge.""" 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 deadline = time.monotonic() + wait_s
while True: while True:
try: 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, """Live Gitea round trip in a repository of the run's own: push a branch,
comment, and prove the L8 reconciler reads external truth correctly. open a PR, comment, and prove the L8 reconciler reads external truth
correctly, and that protection holds against the agent (D4).
Cleans up after itself (closes the PR, deletes the branch) so repeated runs
do not silt up the sandbox.
""" """
import uuid import uuid
from pathlib import Path from pathlib import Path
import pytest 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.config import AgentConfig
from nisse_dev.execution import SSHHost 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.gitea import GiteaClient, pull_request_reconciler
from nisse_dev.state import Intent from nisse_dev.state import Intent
@@ -34,9 +33,21 @@ def intent_for(branch: str) -> Intent:
def test_push_open_pr_and_reconcile( def test_push_open_pr_and_reconcile(
tmp_path: Path, live_config: AgentConfig, gitea: GiteaClient, ssh_host: SSHHost tmp_path: Path, live_config: AgentConfig, gitea: GiteaClient, ssh_host: SSHHost
) -> None: ) -> None:
repo_path = live_config.gitea_repo # the sandbox
client = gitea 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() account = client.whoami()
assert "testing" in account, ( assert "testing" in account, (
f"live tests must run as a testing account, not {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 assert reconcile(intent_for(branch)) is False
# Pushed the way the agent does: from the build VM (here the throwaway # 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. # sshd), with the repository's deploy key, never the API token.
key = push_credential(live_config)
assert key is not None, "nisse-test.yaml names no push credential (D4)"
url = remote_url(client, repo_path, key) url = remote_url(client, repo_path, key)
repo = GitRepo(ssh_host, tmp_path / "work", known_hosts=str(tmp_path / "kh")) repo = GitRepo(ssh_host, tmp_path / "work", known_hosts=str(tmp_path / "kh"))
repo.init() 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.configure_identity(account, f"{account}@example.test")
(repo.root / f"{branch}.txt").write_text("live test artifact\n") (repo.root / f"{branch}.txt").write_text("live test artifact\n")
assert repo.commit_all("Live test commit") is not None assert repo.commit_all("Live test commit") is not None
@@ -93,5 +104,4 @@ def test_push_open_pr_and_reconcile(
finally: finally:
if pr is not None: if pr is not None:
client.close_pull_request(repo_path, pr.number) client.close_pull_request(repo_path, pr.number)
repo.delete_remote_branch(url, branch, credential=key)
client.close() client.close()