CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m17s
From the new loop's first live run (sandbox #255, PR #256): - A question asked on the PR is answered on the PR (an inline one quoted with its file and line, since the API can't reply in its thread), with a pointer in the item's Matrix thread; one asked in Matrix stays there. - wait_for_review: when a review needs no change, or only the operator's merge is left, the item waits on the merge instead of on an answer. - finish_milestone takes the PR's title from the driver. The e2e test's merge waits out Gitea's brief 'try again later' after a push. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
235 lines
9.1 KiB
Python
235 lines
9.1 KiB
Python
"""The end-to-end test on the new loop (Development Plan M2.1, PR D): the
|
|
whole slice, from an issue assigned in Gitea to a merged PR and a closed work
|
|
item, with real models, the Gitea sandbox and a real build host.
|
|
|
|
A scripted operator plays the operator's part: in Gitea through its own test
|
|
account (NISSE_OPERATOR_TOKEN; an operator only in nisse-test.yaml), and in
|
|
the item's Matrix thread through the in-memory transport. Run with
|
|
scripts/e2e.sh after each milestone.
|
|
"""
|
|
|
|
import contextlib
|
|
import os
|
|
import time
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from dataclasses import replace
|
|
from fnmatch import fnmatch
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from e2e_support import base_branch, operator_gitea, task
|
|
|
|
from nisse_dev.config import AgentConfig
|
|
from nisse_dev.execution import SSHHost
|
|
from nisse_dev.gitea import GiteaClient, GiteaError
|
|
from nisse_dev.loop.build import RunningLoop, build_loop_agent
|
|
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, WAITING, loop_of
|
|
from nisse_dev.transport import InMemoryTransport
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.e2e]
|
|
|
|
OPERATOR = "@dongar:dongar.net" # an allowlisted Matrix operator in nisse-test.yaml
|
|
DEADLINE_S = 1200
|
|
MAX_ANSWERS = 5 # per wait for something else, before giving up
|
|
|
|
|
|
@pytest.fixture
|
|
def running(
|
|
live_config: AgentConfig,
|
|
ssh_host: SSHHost,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> Iterator[tuple[RunningLoop, InMemoryTransport, str]]:
|
|
from test_end_to_end import build_host
|
|
|
|
run = uuid.uuid4().hex[:8]
|
|
stack = contextlib.ExitStack()
|
|
# Kept outside pytest's temp directories, which pytest prunes, so a
|
|
# failed run's transcript can be read afterwards (nisse_dev.dump).
|
|
keep = Path(os.environ.get("NISSE_E2E_RUNS", "/tmp/nisse-e2e-runs")) / run
|
|
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))
|
|
config = replace(
|
|
live_config,
|
|
loop="new",
|
|
base_branch=base,
|
|
state_db=keep / "state.db",
|
|
workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
|
|
build_host=build_host(ssh_host),
|
|
)
|
|
transport = InMemoryTransport()
|
|
monkeypatch.setattr("nisse_dev.loop.build.MatrixTransport", lambda cfg: transport)
|
|
agent = None
|
|
try:
|
|
agent = build_loop_agent(config)
|
|
# The test drives intake and PR watching itself, not on a timer.
|
|
agent.loop._interval = 3600
|
|
agent.start()
|
|
yield agent, transport, run
|
|
finally:
|
|
if agent is not None:
|
|
agent.stop()
|
|
host = SSHHost(
|
|
config.build_host.address,
|
|
key=config.build_host.key,
|
|
user=config.build_host.user,
|
|
port=config.build_host.port,
|
|
state_dir=tmp_path / "ssh",
|
|
)
|
|
host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
|
|
stack.close() # the run's base branch
|
|
|
|
|
|
def is_artifact(path: str) -> bool:
|
|
parts = path.split("/")
|
|
return (
|
|
"__pycache__" in parts
|
|
or ".pytest_cache" in parts
|
|
or fnmatch(parts[-1], "*.py[co]")
|
|
)
|
|
|
|
|
|
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."""
|
|
deadline = time.monotonic() + wait_s
|
|
while True:
|
|
try:
|
|
gitea._request(
|
|
"POST", f"/repos/{repo}/pulls/{pr}/merge", json={"Do": "merge"}
|
|
)
|
|
return
|
|
except GiteaError as exc:
|
|
if "try again later" not in str(exc) or time.monotonic() > deadline:
|
|
raise
|
|
time.sleep(3)
|
|
|
|
|
|
class Operator:
|
|
def __init__(self, agent: RunningLoop, transport: InMemoryTransport) -> None:
|
|
self.agent = agent
|
|
self.transport = transport
|
|
|
|
def settle(self) -> None:
|
|
self.agent.worker.call(lambda: None, timeout=DEADLINE_S)
|
|
|
|
def tick(self) -> None:
|
|
"""Intake and PR watching, as the agent's timer would do."""
|
|
self.agent.worker.call(self.agent.loop.intake, timeout=DEADLINE_S)
|
|
|
|
def item(self) -> Any:
|
|
items = self.agent.worker.call(self.agent.store.work_items)
|
|
return items[0] if items else None
|
|
|
|
def say(self, text: str) -> None:
|
|
self.transport.receive(OPERATOR, text, thread=loop_of(self.item()).thread)
|
|
self.settle()
|
|
|
|
def drive_until(self, wait: str) -> Any:
|
|
"""Answer whatever the agent asks until it waits for ``wait``."""
|
|
deadline = time.monotonic() + DEADLINE_S
|
|
answers = 0
|
|
while time.monotonic() < deadline:
|
|
item = self.item()
|
|
assert item is not None, "no work item"
|
|
loop = loop_of(item)
|
|
if item.position == WAITING and loop.wait == wait:
|
|
return item
|
|
if item.status == "closed":
|
|
raise AssertionError(f"closed early: {self.last()}")
|
|
if item.position == WAITING and loop.wait == GO_AHEAD:
|
|
self.say("go")
|
|
elif item.position == WAITING and loop.wait == ANSWER:
|
|
answers += 1
|
|
# Answering forever is how a stuck agent looks: give up.
|
|
assert answers <= MAX_ANSWERS, f"stuck asking: {self.last()}"
|
|
self.say("Use your best judgement and proceed.")
|
|
else:
|
|
self.settle()
|
|
raise AssertionError(f"never waited for {wait}: {self.last()}")
|
|
|
|
def last(self) -> str:
|
|
return self.transport.outbound[-1].text if self.transport.outbound else ""
|
|
|
|
|
|
def test_the_slice_end_to_end(
|
|
running: tuple[RunningLoop, InMemoryTransport, str],
|
|
) -> None:
|
|
agent, transport, run = running
|
|
operator = Operator(agent, transport)
|
|
gitea, repo = agent.gitea, agent.config.gitea_repo
|
|
as_operator = operator_gitea(agent.config.gitea_url)
|
|
issue = None
|
|
pr = 0
|
|
try:
|
|
# --- the operator files an issue and assigns it to the agent (D1) ---
|
|
issue = as_operator.create_issue(repo, f"vowels_{run}", task(run))
|
|
as_operator._request(
|
|
"PATCH",
|
|
f"/repos/{repo}/issues/{issue}",
|
|
json={"assignees": [agent.account]},
|
|
)
|
|
operator.tick()
|
|
item = operator.item()
|
|
assert item is not None and loop_of(item).issue == issue
|
|
|
|
# --- restatement, go-ahead, milestone, PR (L3, D4) ---
|
|
item = operator.drive_until(MERGE)
|
|
pr = loop_of(item).pr
|
|
files = gitea._request("GET", f"/repos/{repo}/pulls/{pr}/files")
|
|
changed = [f["filename"] for f in files]
|
|
assert changed and not [p for p in changed if is_artifact(p)], changed
|
|
body = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["body"]
|
|
assert f"Closes #{issue}" in body
|
|
|
|
# --- a review comment is answered with a fix (D5) ---
|
|
# A request that can't already be met, so a fix must follow.
|
|
marker = f"reviewed-{uuid.uuid4().hex[:8]}"
|
|
before = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["sha"]
|
|
as_operator.post_comment(
|
|
repo, pr, f"Please add the comment line `# {marker}` above vowels_{run}."
|
|
)
|
|
operator.tick()
|
|
operator.drive_until(MERGE)
|
|
after = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["sha"]
|
|
assert after != before, "no fix was pushed for the review comment"
|
|
diff = gitea._http.get(f"/repos/{repo}/pulls/{pr}.diff").text
|
|
assert marker in diff, "the pushed fix doesn't do what the comment asked"
|
|
|
|
# --- the operator approves and merges; the agent closes the item ---
|
|
with contextlib.suppress(GiteaError): # approval may not be required
|
|
as_operator._request(
|
|
"POST",
|
|
f"/repos/{repo}/pulls/{pr}/reviews",
|
|
json={"event": "APPROVED", "body": "looks good"},
|
|
)
|
|
merge(as_operator, repo, pr)
|
|
operator.tick()
|
|
deadline = time.monotonic() + DEADLINE_S
|
|
nudges = 0
|
|
while operator.item().status != "closed" and time.monotonic() < deadline:
|
|
if loop_of(operator.item()).wait == ANSWER:
|
|
nudges += 1
|
|
assert nudges <= MAX_ANSWERS, f"never closed: {operator.last()}"
|
|
operator.say("It's merged; please close the item.")
|
|
operator.settle()
|
|
assert operator.item().outcome == "completed"
|
|
state = gitea._request("GET", f"/repos/{repo}/issues/{issue}")["state"]
|
|
assert state == "closed"
|
|
finally:
|
|
# Leave the sandbox tidy: whatever is still open gets closed.
|
|
item = operator.item()
|
|
if item is not None and item.status == "open":
|
|
operator.say("abandon")
|
|
if issue is not None:
|
|
with contextlib.suppress(GiteaError):
|
|
as_operator.close_issue(repo, issue)
|
|
if pr:
|
|
with contextlib.suppress(GiteaError):
|
|
head = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["ref"]
|
|
as_operator._request("DELETE", f"/repos/{repo}/branches/{head}")
|