Add Gitea client and git operations (B2) — step 6a, part 1
The standalone half of the Gitea layer; orchestrator wiring follows once the executor loop lands, to avoid colliding with it in control.py. - gitea.py: GiteaClient over httpx — open PRs, list and post issue comments, close PRs. find_pull_request is the A3 external-truth query that makes opening a PR idempotent across a crash, and it checks closed PRs too, since a crash after opening must not be answered with "nothing is open, so open another" - pull_request_reconciler: an unreachable Gitea returns None rather than a guess, so the agent gates to the human instead of risking a duplicate side effect - git.py: branch, commit, push for the workspace. The token is passed per-invocation rather than stored as a remote, so it never lands in .git/config, and anything raised is scrubbed of it - commit_all returns None when there is nothing to commit: an empty milestone is a fact to report, not an error - Live test does the real round trip against the sandbox repo and cleans up after itself Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47b2bdfdbd
commit
2071e6ab6d
7 files changed
+614
No files matched your search
@@ -0,0 +1,169 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from nisse_dev.reconcile import Reconciler
|
||||
from nisse_dev.state import Intent
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env: Mapping[str, str] = os.environ) -> "GiteaClient":
|
||||
base_url = env.get("NISSE_GITEA_URL")
|
||||
token = env.get("NISSE_GITEA_TOKEN")
|
||||
if not base_url or not token:
|
||||
raise GiteaError("set NISSE_GITEA_URL and NISSE_GITEA_TOKEN")
|
||||
return cls(base_url, token)
|
||||
|
||||
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? Checks closed PRs too — a crash after opening must not be
|
||||
answered with "no PR is open, so open another"."""
|
||||
for state in ("open", "closed"):
|
||||
data = self._request(
|
||||
"GET", f"/repos/{repo}/pulls", params={"state": state, "limit": 50}
|
||||
)
|
||||
for item in data or []:
|
||||
if (item.get("head") or {}).get("ref") == head:
|
||||
return _pull_request(item)
|
||||
return None
|
||||
|
||||
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 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
|
||||
Reference in new issue
Block a user