M7.1: extract nisse-core
What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
4bbcb09c8e
commit
154a0c3fa8
119 files changed
+1086
-912
No files matched your search
@@ -0,0 +1,399 @@
|
||||
"""Gitea client (architecture D4, D5).
|
||||
|
||||
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 (D7), 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 (L8) asks Gitea whether the
|
||||
effect already happened rather than repeating it.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from nisse_core.reconcile import Reconciler
|
||||
from nisse_core.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
|
||||
merged: bool = False
|
||||
head_sha: str = "" # the commit the PR is at now
|
||||
mergeable: bool | None = None # None: Gitea didn't say
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Approval:
|
||||
"""An approving review, and the commit it approved (L3: approvals bind
|
||||
to exactly what was approved)."""
|
||||
|
||||
author: str
|
||||
commit: str
|
||||
stale: bool = False # Gitea's mark: commits were pushed since
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewNote:
|
||||
"""Something a reviewer said in a review: its body, or a comment on a
|
||||
line (#69). ``key`` is unique across both kinds."""
|
||||
|
||||
key: str
|
||||
author: str
|
||||
body: str
|
||||
where: str # "path:line" for a line comment, "" for a review body
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Comment:
|
||||
id: int
|
||||
author: str
|
||||
body: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Issue:
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
author: str
|
||||
labels: tuple[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 ssh_url(self, repo: str) -> str:
|
||||
"""The repository's SSH clone URL, which carries no credential."""
|
||||
return str(self._request("GET", f"/repos/{repo}")["ssh_url"])
|
||||
|
||||
def clone_url(self, repo: str) -> str:
|
||||
"""The repository's HTTPS clone URL, which carries no credential."""
|
||||
return str(self._request("GET", f"/repos/{repo}")["clone_url"])
|
||||
|
||||
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 pull_request(self, repo: str, index: int) -> PullRequest:
|
||||
return _pull_request(self._request("GET", f"/repos/{repo}/pulls/{index}"))
|
||||
|
||||
def review_notes(self, repo: str, index: int) -> list[ReviewNote]:
|
||||
"""Review bodies and inline line comments on a PR (#69): the PR's
|
||||
conversation comments come from list_comments."""
|
||||
notes: list[ReviewNote] = []
|
||||
for review in (
|
||||
self._request("GET", f"/repos/{repo}/pulls/{index}/reviews") or []
|
||||
):
|
||||
author = (review.get("user") or {}).get("login", "")
|
||||
body = str(review.get("body") or "").strip()
|
||||
if body:
|
||||
notes.append(ReviewNote(f"review-{review['id']}", author, body, ""))
|
||||
if not review.get("comments_count"):
|
||||
continue
|
||||
for comment in (
|
||||
self._request(
|
||||
"GET",
|
||||
f"/repos/{repo}/pulls/{index}/reviews/{review['id']}/comments",
|
||||
)
|
||||
or []
|
||||
):
|
||||
line = comment.get("line") or comment.get("position") or ""
|
||||
notes.append(
|
||||
ReviewNote(
|
||||
key=f"line-{comment['id']}",
|
||||
author=(comment.get("user") or {}).get("login", author),
|
||||
body=str(comment.get("body") or ""),
|
||||
where=f"{comment.get('path', '')}:{line}",
|
||||
)
|
||||
)
|
||||
return notes
|
||||
|
||||
def find_pull_request(self, repo: str, head: str) -> PullRequest | None:
|
||||
"""External truth for L8: 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 assigned_issues(self, repo: str, assignee: str) -> list[Issue]:
|
||||
"""Open issues (not PRs) assigned to ``assignee``: intake (D1)."""
|
||||
data = self._request(
|
||||
"GET",
|
||||
f"/repos/{repo}/issues",
|
||||
params={
|
||||
"type": "issues",
|
||||
"state": "open",
|
||||
"assigned_by": assignee,
|
||||
"limit": 50,
|
||||
},
|
||||
)
|
||||
return [
|
||||
Issue(
|
||||
number=int(item["number"]),
|
||||
title=str(item.get("title") or ""),
|
||||
body=str(item.get("body") or ""),
|
||||
author=str(item["user"]["login"]),
|
||||
labels=tuple(str(label["name"]) for label in item.get("labels") or []),
|
||||
)
|
||||
for item in data or []
|
||||
if not item.get("pull_request")
|
||||
]
|
||||
|
||||
def open_dependencies(self, repo: str, index: int) -> list[str]:
|
||||
"""The issues this one depends on that are still open, as
|
||||
"owner/repo#N": what blocks it (L1)."""
|
||||
data = self._request("GET", f"/repos/{repo}/issues/{index}/dependencies")
|
||||
return [
|
||||
f"{(item.get('repository') or {}).get('full_name', repo)}#{item['number']}"
|
||||
for item in data or []
|
||||
if item.get("state") == "open"
|
||||
]
|
||||
|
||||
def assigned_by(self, repo: str, index: int, assignee: str) -> str | None:
|
||||
"""Who last assigned the issue to ``assignee``, from its timeline;
|
||||
None if the timeline doesn't say."""
|
||||
events = self._request("GET", f"/repos/{repo}/issues/{index}/timeline") or []
|
||||
who = None
|
||||
for event in events:
|
||||
if (
|
||||
event.get("type") == "assignees"
|
||||
and not event.get("removed_assignee")
|
||||
and (event.get("assignee") or {}).get("login") == assignee
|
||||
):
|
||||
who = (event.get("user") or {}).get("login")
|
||||
return who
|
||||
|
||||
def assigned_at(self, repo: str, index: int, assignee: str) -> str | None:
|
||||
"""When the issue was last assigned to ``assignee`` (ISO 8601), from
|
||||
its timeline, oldest first and paged to the end; None if the
|
||||
timeline doesn't say."""
|
||||
when = None
|
||||
for page in range(1, MAX_PR_PAGES + 1):
|
||||
events = (
|
||||
self._request(
|
||||
"GET",
|
||||
f"/repos/{repo}/issues/{index}/timeline",
|
||||
params={"limit": PR_PAGE_SIZE, "page": page},
|
||||
)
|
||||
or []
|
||||
)
|
||||
for event in events:
|
||||
if (
|
||||
event.get("type") == "assignees"
|
||||
and not event.get("removed_assignee")
|
||||
and (event.get("assignee") or {}).get("login") == assignee
|
||||
):
|
||||
when = event.get("created_at")
|
||||
if len(events) < PR_PAGE_SIZE:
|
||||
break
|
||||
return when
|
||||
|
||||
def approvals(self, repo: str, index: int) -> list[Approval]:
|
||||
"""The PR's approving reviews that still stand: not dismissed."""
|
||||
return [
|
||||
Approval(
|
||||
author=(review.get("user") or {}).get("login", ""),
|
||||
commit=str(review.get("commit_id") or ""),
|
||||
stale=bool(review.get("stale")),
|
||||
)
|
||||
for review in (
|
||||
self._request("GET", f"/repos/{repo}/pulls/{index}/reviews") or []
|
||||
)
|
||||
if review.get("state") == "APPROVED" and not review.get("dismissed")
|
||||
]
|
||||
|
||||
def checks(self, repo: str, sha: str) -> str:
|
||||
"""A commit's combined check status: success, pending, failure or
|
||||
error; "none" when no check reported on it at all."""
|
||||
data = self._request("GET", f"/repos/{repo}/commits/{sha}/status") or {}
|
||||
if not data.get("total_count") and not data.get("statuses"):
|
||||
return "none"
|
||||
return str(data.get("state") or "pending")
|
||||
|
||||
def branch_with_file(
|
||||
self, repo: str, *, base: str, branch: str, path: str, text: str, message: str
|
||||
) -> None:
|
||||
"""A new branch off ``base`` with one file added, made through the
|
||||
API: no clone, no workspace."""
|
||||
self._request(
|
||||
"POST",
|
||||
f"/repos/{repo}/contents/{path}",
|
||||
json={
|
||||
"content": base64.b64encode(text.encode()).decode(),
|
||||
"message": message,
|
||||
"branch": base,
|
||||
"new_branch": branch,
|
||||
},
|
||||
)
|
||||
|
||||
def delete_branch(self, repo: str, name: str) -> None:
|
||||
self._request("DELETE", f"/repos/{repo}/branches/{name}")
|
||||
|
||||
def repository(self, repo: str) -> dict[str, Any]:
|
||||
"""The repository as this account sees it, permissions included."""
|
||||
return dict(self._request("GET", f"/repos/{repo}"))
|
||||
|
||||
def branch(self, repo: str, name: str) -> dict[str, Any]:
|
||||
"""A branch as this account sees it: protected, user_can_push."""
|
||||
return dict(self._request("GET", f"/repos/{repo}/branches/{name}"))
|
||||
|
||||
def merge_pull_request(self, repo: str, index: int) -> None:
|
||||
self._request(
|
||||
"POST", f"/repos/{repo}/pulls/{index}/merge", json={"Do": "merge"}
|
||||
)
|
||||
|
||||
def assign_issue(self, repo: str, index: int, assignee: str) -> None:
|
||||
self._request(
|
||||
"PATCH", f"/repos/{repo}/issues/{index}", json={"assignees": [assignee]}
|
||||
)
|
||||
|
||||
def close_issue(self, repo: str, index: int) -> None:
|
||||
self._request(
|
||||
"PATCH", f"/repos/{repo}/issues/{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", ""),
|
||||
merged=bool(data.get("merged")),
|
||||
head_sha=(data.get("head") or {}).get("sha", ""),
|
||||
mergeable=data.get("mergeable"),
|
||||
)
|
||||
|
||||
|
||||
def pull_request_reconciler(client: GiteaClient, repo: str) -> Reconciler:
|
||||
"""Reconciler for an ``open_pr`` intent (L8 boot step 2).
|
||||
|
||||
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