Files
nisse/nisse-review/src/nisse_review/access.py
T
dongarandClaude Opus 5.5 faf973be6c
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m48s
CI / e2e (pull_request) Successful in 28m44s
nisse-review: match repository patterns against Gitea's search
At bring-up, `dev/*` failed: listing an organization's repositories
(/orgs/dev/repos) needs the read:organization token scope, which the
reviewer's token doesn't have and shouldn't need. Patterns are now matched
against /repos/search, which needs only the repository scope and already
lists every repository the account can read. A pattern that matches
nothing is logged (access.no_match). owner_repositories goes.

A Gitea error at startup is now reported like a config error, not a
traceback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-16 22:17:47 +00:00

76 lines
2.7 KiB
Python

"""What nisse-review reviews, and the wall around it (R1, R3).
The repositories are worked out again every round: ``repositories`` in the
configuration is a list of patterns ("dev/nisse", "dev/*", "*/*"), matched
against what the account can read, public repositories included; unset, it
is every repository the account was given (``/user/repos``). Patterns are
matched against Gitea's search, which needs only the token's repository
scope: listing an organization's repositories would need
``read:organization`` too. Access is
managed in Gitea for private repositories and in the configuration for
public ones.
The wall is checked as often: an account that can push to, or administer,
any repository it reviews is refused, at startup and in every round, so a
grant made while it runs stops it.
"""
from collections.abc import Iterable
from fnmatch import fnmatchcase
from nisse_core.gitea import GiteaClient, GiteaNotFound
from nisse_core.oplog import OpLog
_GLOB = frozenset("*?[")
class AccessRefused(RuntimeError):
pass
def _glob(text: str) -> bool:
return any(c in _GLOB for c in text)
def reviewed(
gitea: GiteaClient, patterns: Iterable[str] | None, oplog: OpLog
) -> list[str]:
"""The repositories to review, sorted."""
if patterns is None:
return sorted(gitea.readable_repositories())
found = {p for p in patterns if not _glob(p)} # ``check`` sees they're readable
globs = [p for p in patterns if _glob(p)]
if globs:
visible = gitea.visible_repositories()
for pattern in globs:
matched = {repo for repo in visible if fnmatchcase(repo, pattern)}
if not matched:
oplog.emit("access.no_match", cause="matches nothing", pattern=pattern)
found |= matched
return sorted(found)
def check(gitea: GiteaClient, repos: list[str], oplog: OpLog) -> list[str]:
"""The repositories it can read, after the wall: refused outright if it
could write to any of them. A configured one it can't read is left out."""
readable: list[str] = []
too_much: list[str] = []
for repo in repos:
try:
permissions = gitea.repository(repo).get("permissions") or {}
except GiteaNotFound:
oplog.emit(
"access.unreadable", cause="not found or not readable", repo=repo
)
continue
if permissions.get("push") or permissions.get("admin"):
too_much.append(repo)
elif permissions.get("pull", True):
readable.append(repo)
if too_much:
raise AccessRefused(
f"{gitea.whoami()} can write to {', '.join(too_much)}: nisse-review's "
"account must have read access only (Part R, R3)"
)
return readable