M7.3: nisse-review's first slice
A new package over nisse-core: it reviews every ready PR in the repositories its account can read, and posts one COMMENT review. - scheduler: each round, open PRs per readable repository (or the configured repositories); an item per PR, ready unless draft or WIP; closed with the PR. One review at a time, oldest first. - reader: the repository at head and base from Gitea archives, unpacked and read in Python (read_file, list_files, search_code), and its issues; bound to the item's repository; no git, no shell. - review: starts from the PR, commits, diff, comments, closed issues, CI; comment drafts a point on any existing line; post_review posts one COMMENT review at the head, inline where Gitea takes it, else as links in the summary. ask_expert; vault and memory (memory/nisse-review). - L8: posting is an intent; recovery asks Gitea for the marked review and never posts twice; with Gitea down, the item waits. - Failures retried, then said on the PR until the next push. - Startup refuses an account that can push to or administer any repo. - nisse-core gitea: readable_repositories, open_pull_requests, diff, commits, archive, issue, search_issues, reviews, post_review; PR fields. - Bring-up: scripts/review-bringup.sh; nisse-review.example.yaml. - Tests: unit over a fake Gitea and scripted model; a live test (NISSE_REVIEW_GITEA_TOKEN) reviews a PR in a fresh test repository. - Docs: Development Plan M7.3, Tuning Guide (tuning.review.*). 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
fc71d3da75
commit
f59d974a95
24 files changed
+2330
-7
No files matched your search
@@ -11,6 +11,7 @@ effect already happened rather than repeating it.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -36,6 +37,30 @@ class PullRequest:
|
||||
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)
|
||||
@@ -104,6 +129,19 @@ class GiteaClient:
|
||||
)
|
||||
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, an archive."""
|
||||
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"])
|
||||
@@ -363,10 +401,118 @@ class GiteaClient:
|
||||
"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 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 archive(self, repo: str, ref: str) -> bytes:
|
||||
"""The repository at ``ref`` (a commit or branch), as a .tar.gz."""
|
||||
return self._raw(f"/repos/{repo}/archive/{ref}.tar.gz")
|
||||
|
||||
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 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"],
|
||||
@@ -376,6 +522,11 @@ def _pull_request(data: dict[str, Any]) -> PullRequest:
|
||||
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")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user