Archives were downloaded and unpacked afresh for every run, which doesn't scale to very large repositories. Each repository is now cloned once, with its whole history, under scratch/repos/<owner>/<name>/ (head/ the clone, base/ a worktree when read). A run fetches every branch and the PR's refs/pull/N/head, checks out the head exactly, and fails if that head is gone. Only the latest repository is kept for now. Git runs walled off: none of the host's git configuration, links checked out as plain files, no hooks, submodules or LFS, only the Gitea URL's protocol, and the token as an http.extraHeader for that URL in git's environment only. A stop ends a git command; a cut-short first clone is removed. The reader never shows .git. GiteaClient.archive is gone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
595 lines
21 KiB
Python
595 lines
21 KiB
Python
"""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 collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from nisse_core.state.reconcile import Reconciler
|
|
from nisse_core.state.store import Intent
|
|
|
|
PR_PAGE_SIZE = 50
|
|
MAX_PR_PAGES = 40 # 2000 PRs; beyond that, say so rather than guess
|
|
|
|
|
|
class GiteaError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class GiteaNotFound(GiteaError):
|
|
"""A 404: no such thing, or nothing this account may see."""
|
|
|
|
|
|
@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
|
|
title: str = ""
|
|
body: str = ""
|
|
author: str = ""
|
|
base: str = "" # the branch it would merge into
|
|
draft: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Review:
|
|
id: int
|
|
author: str
|
|
state: str # COMMENT, APPROVED, REQUEST_CHANGES, ...
|
|
commit: str # the commit it reviewed
|
|
body: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LineComment:
|
|
"""A review comment on one line of a file, as posted: ``line`` in the
|
|
file at the PR's head."""
|
|
|
|
path: str
|
|
line: int
|
|
body: str
|
|
|
|
|
|
@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:
|
|
error = GiteaNotFound if response.status_code == 404 else GiteaError
|
|
raise error(
|
|
f"gitea {method} {path} returned {response.status_code}: "
|
|
f"{response.text[:300]}"
|
|
)
|
|
return response.json() if response.content else None
|
|
|
|
def _raw(self, path: str, **kwargs: Any) -> bytes:
|
|
"""A GET whose answer isn't JSON: a diff."""
|
|
try:
|
|
response = self._http.get(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 GET {path} returned {response.status_code}: "
|
|
f"{response.text[:300]}"
|
|
)
|
|
return response.content
|
|
|
|
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 readable_repositories(self) -> list[str]:
|
|
"""Every repository this account can read, as "owner/name"."""
|
|
found: list[str] = []
|
|
for page in range(1, MAX_PR_PAGES + 1):
|
|
items = (
|
|
self._request(
|
|
"GET", "/user/repos", params={"limit": PR_PAGE_SIZE, "page": page}
|
|
)
|
|
or []
|
|
)
|
|
found += [str(item["full_name"]) for item in items]
|
|
if len(items) < PR_PAGE_SIZE:
|
|
return found
|
|
raise GiteaError("too many repositories to list")
|
|
|
|
def visible_repositories(self) -> list[str]:
|
|
"""Every repository this account can read, public ones included."""
|
|
return self._paged_names("/repos/search", key="data")
|
|
|
|
def _paged_names(self, path: str, key: str | None = None) -> list[str]:
|
|
found: list[str] = []
|
|
for page in range(1, MAX_PR_PAGES + 1):
|
|
answer = self._request(
|
|
"GET", path, params={"limit": PR_PAGE_SIZE, "page": page}
|
|
)
|
|
items = (answer or {}).get(key, []) if key else (answer or [])
|
|
found += [str(item["full_name"]) for item in items]
|
|
if len(items) < PR_PAGE_SIZE:
|
|
return found
|
|
raise GiteaError("too many repositories to list")
|
|
|
|
def open_pull_requests(self, repo: str) -> list[PullRequest]:
|
|
found: list[PullRequest] = []
|
|
for page in range(1, MAX_PR_PAGES + 1):
|
|
items = (
|
|
self._request(
|
|
"GET",
|
|
f"/repos/{repo}/pulls",
|
|
params={"state": "open", "limit": PR_PAGE_SIZE, "page": page},
|
|
)
|
|
or []
|
|
)
|
|
found += [_pull_request(item) for item in items]
|
|
if len(items) < PR_PAGE_SIZE:
|
|
return found
|
|
raise GiteaError(f"too many open pull requests in {repo} to list")
|
|
|
|
def pull_request_diff(self, repo: str, index: int) -> str:
|
|
return self._raw(f"/repos/{repo}/pulls/{index}.diff").decode("utf-8", "replace")
|
|
|
|
def pull_request_commits(self, repo: str, index: int) -> list[str]:
|
|
"""The PR's commits, oldest first, as "sha7 first line"."""
|
|
items = self._request("GET", f"/repos/{repo}/pulls/{index}/commits") or []
|
|
found = []
|
|
for item in items:
|
|
message = str((item.get("commit") or {}).get("message") or "")
|
|
first = message.splitlines()[0] if message.strip() else ""
|
|
found.append(f"{str(item.get('sha', ''))[:7]} {first}".strip())
|
|
return found
|
|
|
|
def issue(self, repo: str, index: int) -> Issue:
|
|
return _issue(self._request("GET", f"/repos/{repo}/issues/{index}"))
|
|
|
|
def search_issues(self, repo: str, text: str) -> list[Issue]:
|
|
"""Issues (not PRs), open or closed, whose text matches."""
|
|
items = self._request(
|
|
"GET",
|
|
f"/repos/{repo}/issues",
|
|
params={"q": text, "type": "issues", "state": "all", "limit": 20},
|
|
)
|
|
return [_issue(item) for item in items or []]
|
|
|
|
def reviews(self, repo: str, index: int) -> list[Review]:
|
|
return [
|
|
Review(
|
|
id=int(item["id"]),
|
|
author=(item.get("user") or {}).get("login", ""),
|
|
state=str(item.get("state") or ""),
|
|
commit=str(item.get("commit_id") or ""),
|
|
body=str(item.get("body") or ""),
|
|
)
|
|
for item in self._request("GET", f"/repos/{repo}/pulls/{index}/reviews")
|
|
or []
|
|
]
|
|
|
|
def post_review(
|
|
self,
|
|
repo: str,
|
|
index: int,
|
|
*,
|
|
body: str,
|
|
commit: str,
|
|
comments: Sequence[LineComment] = (),
|
|
) -> int:
|
|
"""A COMMENT review: never an approval or a request for changes."""
|
|
data = self._request(
|
|
"POST",
|
|
f"/repos/{repo}/pulls/{index}/reviews",
|
|
json={
|
|
"event": "COMMENT",
|
|
"body": body,
|
|
"commit_id": commit,
|
|
"comments": [
|
|
{"path": c.path, "new_position": c.line, "body": c.body}
|
|
for c in comments
|
|
],
|
|
},
|
|
)
|
|
return int(data["id"])
|
|
|
|
def commits_between(self, repo: str, base: str, head: str) -> list[str]:
|
|
"""The commits from ``base`` to ``head`` ("sha subject"), oldest
|
|
first. After a force-push that drops ``base``, Gitea counts from the
|
|
two heads' merge base."""
|
|
data = self._request("GET", f"/repos/{repo}/compare/{base}...{head}") or {}
|
|
found: list[str] = []
|
|
for commit in data.get("commits") or []:
|
|
message = str((commit.get("commit") or {}).get("message") or "")
|
|
subject = message.splitlines()[0] if message.strip() else ""
|
|
found.append(f"{commit['sha']} {subject}".rstrip())
|
|
return found[::-1] # Gitea lists the newest first
|
|
|
|
def commit_diff(self, repo: str, sha: str) -> str:
|
|
"""One commit's diff. Through the API, so a token reads private
|
|
repositories (the web's compare diff doesn't take one)."""
|
|
return self._raw(f"/repos/{repo}/git/commits/{sha}.diff").decode(
|
|
errors="replace"
|
|
)
|
|
|
|
def react(self, repo: str, comment_id: int, content: str) -> None:
|
|
"""A reaction (``+1``, ``eyes``, ...) on a PR's or issue's comment."""
|
|
self._request(
|
|
"POST",
|
|
f"/repos/{repo}/issues/comments/{comment_id}/reactions",
|
|
json={"content": content},
|
|
)
|
|
|
|
def close(self) -> None:
|
|
self._http.close()
|
|
|
|
|
|
def _issue(item: dict[str, Any]) -> Issue:
|
|
return Issue(
|
|
number=int(item["number"]),
|
|
title=str(item.get("title") or ""),
|
|
body=str(item.get("body") or ""),
|
|
author=str((item.get("user") or {}).get("login", "")),
|
|
labels=tuple(str(label["name"]) for label in item.get("labels") or []),
|
|
)
|
|
|
|
|
|
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"),
|
|
title=str(data.get("title") or ""),
|
|
body=str(data.get("body") or ""),
|
|
author=str((data.get("user") or {}).get("login", "")),
|
|
base=str((data.get("base") or {}).get("ref", "")),
|
|
draft=bool(data.get("draft")),
|
|
)
|
|
|
|
|
|
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
|