CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s
With no production instance yet, there is no reason to carry two ways
of configuring the agent. Every environment variable that duplicated a
config key is gone; the environment now supplies only the secrets the
file references by ${…}, plus NISSE_CONFIG and NISSE_ENV to find the
files.
- AgentConfig loads from a file (load, from_file) or a parsed tree
(from_tree, for tests); a missing file is an error naming the example
- The from_env constructors on LiteLLMClient, AnthropicPassthroughClient,
GiteaClient, MatrixConfig and RoleMap are removed, with the env
reading for max output tokens; clients take their settings from config
- The live integration tests share a committed agent config,
tests/integration/nisse-test.yaml, aimed at test resources, and one
conftest that loads it and builds clients. NISSE_LIVE_PLANNER_MODEL
becomes its planner role; NISSE_CAPABILITY_MODELS stays as a per-run
switch
- A missing secret now fails the live tests instead of skipping them
- CI passes only the three tokens
- .env.example is secrets only; spec A5 says config comes from the file
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
184 lines
5.9 KiB
Python
184 lines
5.9 KiB
Python
"""Gitea client (spec B2).
|
|
|
|
Outbound: open PRs and post comments through a dedicated service account with
|
|
a scoped token. Inbound is deliberately *polled* rather than webhook-driven,
|
|
so the agent VM stays outbound-only (B3), and intake is structured JSON —
|
|
never routed through a chat bridge.
|
|
|
|
``find_pull_request`` is the external-truth query that makes opening a PR
|
|
idempotent across a crash: boot reconciliation (A3) asks Gitea whether the
|
|
effect already happened rather than repeating it.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from nisse_dev.reconcile import Reconciler
|
|
from nisse_dev.state import Intent
|
|
|
|
PR_PAGE_SIZE = 50
|
|
MAX_PR_PAGES = 40 # 2000 PRs; beyond that, say so rather than guess
|
|
|
|
|
|
class GiteaError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PullRequest:
|
|
number: int
|
|
state: str
|
|
head: str
|
|
url: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Comment:
|
|
id: int
|
|
author: str
|
|
body: str
|
|
created_at: str
|
|
|
|
|
|
class GiteaClient:
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
token: str,
|
|
*,
|
|
timeout_s: float = 30.0,
|
|
transport: httpx.BaseTransport | None = None,
|
|
) -> None:
|
|
self._http = httpx.Client(
|
|
base_url=f"{base_url.rstrip('/')}/api/v1",
|
|
headers={"Authorization": f"token {token}"},
|
|
timeout=timeout_s,
|
|
transport=transport,
|
|
)
|
|
|
|
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
try:
|
|
response = self._http.request(method, path, **kwargs)
|
|
except httpx.HTTPError as exc:
|
|
raise GiteaError(f"gitea request failed: {exc}") from exc
|
|
if response.status_code >= 400:
|
|
raise GiteaError(
|
|
f"gitea {method} {path} returned {response.status_code}: "
|
|
f"{response.text[:300]}"
|
|
)
|
|
return response.json() if response.content else None
|
|
|
|
def whoami(self) -> str:
|
|
return str(self._request("GET", "/user")["login"])
|
|
|
|
def open_pull_request(
|
|
self, repo: str, *, head: str, base: str, title: str, body: str
|
|
) -> PullRequest:
|
|
data = self._request(
|
|
"POST",
|
|
f"/repos/{repo}/pulls",
|
|
json={"head": head, "base": base, "title": title, "body": body},
|
|
)
|
|
return _pull_request(data)
|
|
|
|
def find_pull_request(self, repo: str, head: str) -> PullRequest | None:
|
|
"""External truth for A3: did the PR for this branch already get
|
|
opened? Includes closed PRs — a crash after opening must not be
|
|
answered with "no PR is open, so open another".
|
|
|
|
Pages to the end rather than sampling the first page: a "not found"
|
|
that only means "not in the newest 50" would licence a duplicate PR.
|
|
If the history is longer than this walk, raise instead of guessing —
|
|
the reconciler turns that into "unverifiable" and gates to the human.
|
|
"""
|
|
for page in range(1, MAX_PR_PAGES + 1):
|
|
data = self._request(
|
|
"GET",
|
|
f"/repos/{repo}/pulls",
|
|
params={"state": "all", "limit": PR_PAGE_SIZE, "page": page},
|
|
)
|
|
items = data or []
|
|
for item in items:
|
|
if (item.get("head") or {}).get("ref") == head:
|
|
return _pull_request(item)
|
|
if len(items) < PR_PAGE_SIZE:
|
|
return None # reached the end: definitively absent
|
|
raise GiteaError(
|
|
f"searched {MAX_PR_PAGES * PR_PAGE_SIZE} pull requests in {repo} "
|
|
f"without reaching the end; cannot confirm whether {head!r} was "
|
|
"already opened"
|
|
)
|
|
|
|
def list_comments(
|
|
self, repo: str, index: int, since: str | None = None
|
|
) -> list[Comment]:
|
|
params: dict[str, str] = {}
|
|
if since is not None:
|
|
params["since"] = since
|
|
data = self._request(
|
|
"GET", f"/repos/{repo}/issues/{index}/comments", params=params
|
|
)
|
|
return [
|
|
Comment(
|
|
id=item["id"],
|
|
author=(item.get("user") or {}).get("login", ""),
|
|
body=item.get("body", ""),
|
|
created_at=item.get("created_at", ""),
|
|
)
|
|
for item in data or []
|
|
]
|
|
|
|
def post_comment(self, repo: str, index: int, body: str) -> Comment:
|
|
data = self._request(
|
|
"POST", f"/repos/{repo}/issues/{index}/comments", json={"body": body}
|
|
)
|
|
return Comment(
|
|
id=data["id"],
|
|
author=(data.get("user") or {}).get("login", ""),
|
|
body=data.get("body", ""),
|
|
created_at=data.get("created_at", ""),
|
|
)
|
|
|
|
def create_issue(self, repo: str, title: str, body: str) -> int:
|
|
data = self._request(
|
|
"POST", f"/repos/{repo}/issues", json={"title": title, "body": body}
|
|
)
|
|
return int(data["number"])
|
|
|
|
def close_pull_request(self, repo: str, index: int) -> None:
|
|
self._request("PATCH", f"/repos/{repo}/pulls/{index}", json={"state": "closed"})
|
|
|
|
def close(self) -> None:
|
|
self._http.close()
|
|
|
|
|
|
def _pull_request(data: dict[str, Any]) -> PullRequest:
|
|
return PullRequest(
|
|
number=data["number"],
|
|
state=data.get("state", ""),
|
|
head=(data.get("head") or {}).get("ref", ""),
|
|
url=data.get("html_url", ""),
|
|
)
|
|
|
|
|
|
def pull_request_reconciler(client: GiteaClient, repo: str) -> Reconciler:
|
|
"""Reconciler for an ``open_pr`` intent (A3 step 4).
|
|
|
|
An unreachable Gitea yields None — unverifiable, so the agent gates to
|
|
the human rather than risking a duplicate PR. That is the conservative
|
|
direction: a delayed decision costs nothing, a repeated side effect does.
|
|
"""
|
|
|
|
def check(intent: Intent) -> bool | None:
|
|
branch = intent.payload.get("branch")
|
|
if not branch:
|
|
return None
|
|
try:
|
|
return client.find_pull_request(repo, branch) is not None
|
|
except GiteaError:
|
|
return None
|
|
|
|
return check
|