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

+63 -3
View File
@@ -15,6 +15,7 @@ from nisse_core.knowledge.vault import Vault
from nisse_core.log.canonical import AssistantText
from nisse_core.loop.driver import Driver
from nisse_core.oplog import OpLog
from nisse_review.access import AccessRefused, check, reviewed
from nisse_review.build import StartupError, check_access
from nisse_review.review import Reviewer, _diff_lines, marker
from nisse_review.scheduler import Scheduler, ready
@@ -102,7 +103,7 @@ def test_intake_opens_an_item_per_pr_and_closes_it_with_the_pr(
assert (item(w, 7).position, item(w, 8).position) == (READY, WAITING)
assert item(w, 7).scope == REPO
gitea.repos["o/elsewhere"] = [pr(9)]
w["scheduler"]._only = frozenset({REPO})
w["scheduler"]._patterns = frozenset({REPO})
w["scheduler"].intake()
assert w["store"].work_item("o/elsewhere#9") is None
gitea.repos[REPO] = [pr(8, draft=False)] # 7 merged; 8 is ready now
@@ -300,10 +301,69 @@ def test_with_gitea_down_a_crashed_review_waits(tmp_path: Path) -> None:
def test_an_account_that_can_write_is_refused(tmp_path: Path) -> None:
gitea = FakeGitea(repos={REPO: [], "o/other": []})
assert check_access(gitea) == ("nisse-review", [REPO, "o/other"]) # type: ignore[arg-type]
log = OpLog("t", io.StringIO())
assert check_access(gitea, None, log) == ("nisse-review", [REPO, "o/other"]) # type: ignore[arg-type]
gitea.permissions["o/other"] = {"pull": True, "push": True}
with pytest.raises(StartupError, match="read access only"):
check_access(gitea) # type: ignore[arg-type]
check_access(gitea, None, log) # type: ignore[arg-type]
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).
gitea = FakeGitea(
repos={"dev/nisse": [pr(7)], "dev/wordle": [], "infra/tofu": [pr(3)]},
public={"dev/nisse", "dev/wordle", "infra/tofu"},
)
log = OpLog("t", io.StringIO())
assert reviewed(gitea, None, log) == [] # type: ignore[arg-type]
assert reviewed(gitea, {"dev/*"}, log) == ["dev/nisse", "dev/wordle"] # type: ignore[arg-type]
assert reviewed(gitea, {"*/*"}, log) == ["dev/nisse", "dev/wordle", "infra/tofu"] # type: ignore[arg-type]
assert reviewed(gitea, {"dev/n*", "infra/tofu"}, log) == ["dev/nisse", "infra/tofu"] # type: ignore[arg-type]
w = world(tmp_path, Scripted(), gitea, patterns=frozenset({"dev/*"}))
w["scheduler"].round()
assert w["store"].work_item("dev/nisse#7") is not None
assert w["store"].work_item("infra/tofu#3") is None
def test_a_configured_repository_it_cannot_read_is_left_out() -> None:
gitea = FakeGitea(repos={REPO: []})
out = io.StringIO()
log = OpLog("t", out)
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()
def test_write_access_granted_while_running_stops_it(tmp_path: Path) -> None:
gitea = FakeGitea(repos={REPO: [pr()]})
w = world(tmp_path, Scripted(), gitea)
w["scheduler"].intake()
gitea.permissions[REPO] = {"pull": True, "push": True}
with pytest.raises(AccessRefused, match="read access only"):
w["scheduler"].round()
assert item(w).position == READY # nothing reviewed
def test_a_repository_pattern_must_be_owner_slash_name(tmp_path: Path) -> None:
from nisse_core.config import ConfigError
from nisse_review.config import ReviewConfig
tree = {
"state_db": str(tmp_path / "s.db"),
"llm": {"base_url": "http://llm", "token": "k"},
"roles": {"reviewer": "local", "expert": "frontier"},
"gitea": {"url": "https://g", "token": "t", "operators": ["don"]},
}
ok = ReviewConfig.from_tree({**tree, "repositories": ["dev/*", "*/*"]}, {})
assert ok.repositories == {"dev/*", "*/*"}
for bad in ("dev", "dev/", "/nisse", "a/b/c"):
with pytest.raises(ConfigError, match="is not owner/name"):
ReviewConfig.from_tree({**tree, "repositories": [bad]}, {})
def test_diff_lines_are_the_heads_lines_a_diff_shows() -> None: