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
This commit is contained in:
1 parent
86f899006a
commit
faf973be6c
8 files changed
+25
-58
No files matched your search
@@ -3,7 +3,10 @@
|
||||
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``). Access is
|
||||
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.
|
||||
|
||||
@@ -35,22 +38,15 @@ def reviewed(
|
||||
"""The repositories to review, sorted."""
|
||||
if patterns is None:
|
||||
return sorted(gitea.readable_repositories())
|
||||
found: set[str] = set()
|
||||
for pattern in patterns:
|
||||
owner, _, name = pattern.partition("/")
|
||||
if not _glob(owner) and not _glob(name):
|
||||
found.add(pattern) # checked by ``check``: it must be readable
|
||||
continue
|
||||
try:
|
||||
candidates = (
|
||||
gitea.visible_repositories()
|
||||
if _glob(owner)
|
||||
else gitea.owner_repositories(owner)
|
||||
)
|
||||
except GiteaNotFound:
|
||||
oplog.emit("access.no_owner", cause="no such owner", pattern=pattern)
|
||||
continue
|
||||
found |= {repo for repo in candidates if fnmatchcase(repo, pattern)}
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from types import FrameType
|
||||
|
||||
from nisse_core.config import ConfigError
|
||||
from nisse_core.envfile import load_env_file
|
||||
from nisse_core.gitea import GiteaError
|
||||
from nisse_review.access import AccessRefused
|
||||
from nisse_review.build import StartupError, build
|
||||
from nisse_review.config import ReviewConfig
|
||||
@@ -19,7 +20,7 @@ def main() -> int:
|
||||
try:
|
||||
config = ReviewConfig.load()
|
||||
built = build(config)
|
||||
except (ConfigError, StartupError) as exc:
|
||||
except (ConfigError, StartupError, GiteaError) as exc:
|
||||
print(f"nisse-review: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
@@ -70,8 +70,7 @@ class FakeGitea:
|
||||
comments: list[tuple[str, int, str]] = field(default_factory=list)
|
||||
refuse_off_diff: bool = False
|
||||
down: bool = False
|
||||
# Readable but not given (public): missing from /user/repos, as Gitea
|
||||
# leaves a public repository's Read collaborators out of it.
|
||||
# Readable but not given (public, no grant): missing from /user/repos.
|
||||
public: set[str] = field(default_factory=set)
|
||||
|
||||
def _up(self) -> None:
|
||||
@@ -89,13 +88,6 @@ class FakeGitea:
|
||||
self._up()
|
||||
return list(self.repos)
|
||||
|
||||
def owner_repositories(self, owner: str) -> list[str]:
|
||||
self._up()
|
||||
found = [r for r in self.repos if r.startswith(f"{owner}/")]
|
||||
if not found:
|
||||
raise GiteaNotFound(f"no owner {owner}")
|
||||
return found
|
||||
|
||||
def repository(self, repo: str) -> dict[str, Any]:
|
||||
if repo not in self.repos:
|
||||
raise GiteaNotFound(f"no repository {repo}")
|
||||
|
||||
@@ -311,9 +311,8 @@ def test_an_account_that_can_write_is_refused(tmp_path: Path) -> None:
|
||||
def test_patterns_reach_public_repositories_the_account_was_not_given(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Gitea leaves a public repository's Read collaborators out of
|
||||
# /user/repos: a pattern finds it all the same (option a: the
|
||||
# configuration decides).
|
||||
# A public repository the account wasn't given isn't in /user/repos: a
|
||||
# pattern finds it all the same (option a: the configuration decides).
|
||||
gitea = FakeGitea(
|
||||
repos={"dev/nisse": [pr(7)], "dev/wordle": [], "infra/tofu": [pr(3)]},
|
||||
public={"dev/nisse", "dev/wordle", "infra/tofu"},
|
||||
@@ -336,7 +335,7 @@ def test_a_configured_repository_it_cannot_read_is_left_out() -> None:
|
||||
patterns = {REPO, "o/gone", "nobody/*"}
|
||||
assert check(gitea, reviewed(gitea, patterns, log), log) == [REPO] # type: ignore[arg-type]
|
||||
assert '"access.unreadable"' in out.getvalue() and "o/gone" in out.getvalue()
|
||||
assert '"access.no_owner"' in out.getvalue()
|
||||
assert '"access.no_match"' in out.getvalue() and "nobody/*" in out.getvalue()
|
||||
|
||||
|
||||
def test_write_access_granted_while_running_stops_it(tmp_path: Path) -> None:
|
||||
|
||||
Reference in new issue
Block a user