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
76 lines
2.7 KiB
Python
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
|