nisse-review: repository patterns, and the wall checked every round
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m56s
CI / e2e (pull_request) Successful in 9m52s

At bring-up, nisse-review read no repositories: Gitea's /user/repos leaves
out a public repository's Read collaborators, so dev/nisse never showed up.
`repositories` now holds patterns (dev/nisse, dev/*, */*) matched against
every repository the account can read, public ones included (an
organization's or user's list, or the instance's search); unset, it is
/user/repos as before. A configured repository it can't read is left out
with an oplog line.

The read-only wall (R3) is checked every round, not only at startup: an
account granted push or admin while running stops (exit 3) rather than
review on.

nisse-core's Gitea client gains GiteaNotFound for 404s, owner_repositories
and visible_repositories.

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:
dongarandClaude Opus 5.5 committed 2026-10-16 21:41:53 +00:00
1 parent 52c6704b17
commit 2139ddd05c
12 files changed
+283 -44

No files matched your search

+30 -1
View File
@@ -28,6 +28,10 @@ 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
@@ -123,7 +127,8 @@ class GiteaClient:
except httpx.HTTPError as exc:
raise GiteaError(f"gitea request failed: {exc}") from exc
if response.status_code >= 400:
raise GiteaError(
error = GiteaNotFound if response.status_code == 404 else GiteaError
raise error(
f"gitea {method} {path} returned {response.status_code}: "
f"{response.text[:300]}"
)
@@ -416,6 +421,30 @@ class GiteaClient:
return found
raise GiteaError("too many repositories to list")
def owner_repositories(self, owner: str) -> list[str]:
"""Every repository of an organization (or a user) this account can
read, as "owner/name"."""
try:
return self._paged_names(f"/orgs/{owner}/repos")
except GiteaNotFound:
return self._paged_names(f"/users/{owner}/repos")
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):
+28
View File
@@ -8,6 +8,7 @@ from nisse_core.gitea import (
Comment,
GiteaClient,
GiteaError,
GiteaNotFound,
pull_request_reconciler,
)
from nisse_core.state.store import Intent
@@ -360,6 +361,33 @@ def test_readable_repositories_page_to_the_end() -> None:
assert len(found) == PR_PAGE_SIZE + 2 and found[-1] == "o/r2-1"
def test_owner_repositories_try_the_organization_then_the_user() -> None:
asked: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
asked.append(request.url.path)
if request.url.path.startswith("/api/v1/orgs/"):
return httpx.Response(404, json={"message": "not found"})
return httpx.Response(200, json=[{"full_name": "don/r"}])
assert client(handler).owner_repositories("don") == ["don/r"]
assert asked == ["/api/v1/orgs/don/repos", "/api/v1/users/don/repos"]
def test_visible_repositories_read_the_search_and_a_404_is_not_found() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/repos/search"):
return httpx.Response(
200, json={"ok": True, "data": [{"full_name": "d/n"}]}
)
return httpx.Response(404, json={"message": "not found"})
gitea = client(handler)
assert gitea.visible_repositories() == ["d/n"]
with pytest.raises(GiteaNotFound):
gitea.repository("d/gone")
def test_a_pull_request_carries_what_a_review_needs() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith(".diff"):