A repository per test run instead of the shared sandbox #107
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Executable
+31
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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("-")
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user