Merge pull request 'nisse-review: repository patterns, and the wall checked every round' (#153) from review-repo-patterns into main
CI / checks (push) Successful in 2m53s
CI / e2e (push) Skipped

This commit was merged in pull request #153.
This commit is contained in:
dev-agent committed 2026-10-16 22:15:28 +00:00
commit 86f899006a
12 files changed
+283 -44

No files matched your search

+11 -4
View File
@@ -878,9 +878,13 @@ else) towards better changes.
### R1. How it works ### R1. How it works
- **The duty:** watching the repositories its Gitea account can read for PRs - **The duty:** watching its repositories for PRs ready for review: open, not
ready for review: open, not a draft, and not marked work in progress. Any a draft, and not marked work in progress. Any author's: the operator's,
author's: the operator's, `nisse-dev`'s, or another tool's. `nisse-dev`'s, or another tool's. Its repositories are the ones its Gitea
account was given, or, when configured, those matching patterns (`dev/*`,
`*/*`) among all it can read, public ones included: Gitea leaves a public
repository's Read collaborators out of an account's own list, so access to
public repositories is managed in configuration, and to private ones in Gitea.
- **A work item per PR,** scoped to its repository (L1). It closes when the PR - **A work item per PR,** scoped to its repository (L1). It closes when the PR
merges or closes. merges or closes.
- **What starts a run:** the PR becoming ready, a new push, or a new comment or - **What starts a run:** the PR becoming ready, a new push, or a new comment or
@@ -912,7 +916,10 @@ Two agents can argue forever, so a PR's exchanges are capped.
### R3. Authority and reach ### R3. Authority and reach
- **Read-only on the repository:** code at any ref (not only what the PR touches), - **Read-only on the repository:** code at any ref (not only what the PR touches),
documents, issues, other PRs, CI status, through its Gitea account. Reads are documents, issues, other PRs, CI status, through its Gitea account. The wall is
checked at startup and every round: an account that can push to, or
administer, a repository it reviews is refused, and stops if granted that
while running. Reads are
bound by the harness to the item's repository, so what it might repeat in a bound by the harness to the item's repository, so what it might repeat in a
comment is already visible to that repository's readers. comment is already visible to that repository's readers.
- **It writes only comments and reviews on the item's PR.** Its reviews never - **It writes only comments and reviews on the item's PR.** Its reviews never
+4
View File
@@ -607,6 +607,10 @@ waits until two exist to compare. Moved, unchanged in behaviour:
account can read (or the configured `repositories`); a work item per PR, account can read (or the configured `repositories`); a work item per PR,
ready unless a draft or titled WIP; closed when the PR merges or closes. ready unless a draft or titled WIP; closed when the PR merges or closes.
Every open PR is reviewed, those open when it starts included (operator). Every open PR is reviewed, those open when it starts included (operator).
Since #153, `repositories` holds patterns (`dev/*`, `*/*`) matched against
every repository it can read, public ones included (Gitea's own list for the
account leaves out a public repository's Read collaborators, found at
bring-up); the read-only wall is checked every round, not only at startup.
- **Reading (R3):** the repository at the PR's head and base, from Gitea's - **Reading (R3):** the repository at the PR's head and base, from Gitea's
archives, unpacked into a scratch folder and read in Python (`read_file`, archives, unpacked into a scratch folder and read in Python (`read_file`,
`list_files`, `search_code`), and its issues (`read_issue`, `list_files`, `search_code`), and its issues (`read_issue`,
+30 -1
View File
@@ -28,6 +28,10 @@ class GiteaError(RuntimeError):
pass pass
class GiteaNotFound(GiteaError):
"""A 404: no such thing, or nothing this account may see."""
@dataclass(frozen=True) @dataclass(frozen=True)
class PullRequest: class PullRequest:
number: int number: int
@@ -123,7 +127,8 @@ class GiteaClient:
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise GiteaError(f"gitea request failed: {exc}") from exc raise GiteaError(f"gitea request failed: {exc}") from exc
if response.status_code >= 400: 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"gitea {method} {path} returned {response.status_code}: "
f"{response.text[:300]}" f"{response.text[:300]}"
) )
@@ -416,6 +421,30 @@ class GiteaClient:
return found return found
raise GiteaError("too many repositories to list") 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]: def open_pull_requests(self, repo: str) -> list[PullRequest]:
found: list[PullRequest] = [] found: list[PullRequest] = []
for page in range(1, MAX_PR_PAGES + 1): for page in range(1, MAX_PR_PAGES + 1):
+28
View File
@@ -8,6 +8,7 @@ from nisse_core.gitea import (
Comment, Comment,
GiteaClient, GiteaClient,
GiteaError, GiteaError,
GiteaNotFound,
pull_request_reconciler, pull_request_reconciler,
) )
from nisse_core.state.store import Intent 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" 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 test_a_pull_request_carries_what_a_review_needs() -> None:
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith(".diff"): if request.url.path.endswith(".diff"):
+5 -3
View File
@@ -4,8 +4,8 @@
# - Secrets are ${VARIABLE} references, resolved from the environment (or the # - Secrets are ${VARIABLE} references, resolved from the environment (or the
# .env file NISSE_ENV names) at startup. Nothing else goes there. # .env file NISSE_ENV names) at startup. Nothing else goes there.
# - Unknown keys are errors. # - Unknown keys are errors.
# - Its Gitea account must have read access only, to every repository it can # - Its Gitea account must have read access only to every repository it
# see: startup refuses an account that can push to one (R3). # reviews: it refuses to start, and stops, if it can push to one (R3).
state_db: ~/nisse-review/state.db state_db: ~/nisse-review/state.db
@@ -24,7 +24,9 @@ gitea:
token: ${NISSE_GITEA_TOKEN} # the nisse-review account's token: ${NISSE_GITEA_TOKEN} # the nisse-review account's
operators: [operator] # whose comments are the operator's (R4) operators: [operator] # whose comments are the operator's (R4)
# repositories: [owner/repo] # only these; unset, every one it can read # repositories: ["dev/*"] # patterns: owner/name, owner/*, */*;
# # public repositories included. Unset:
# # every one its account was given.
# vault: # L10; memory/nisse-review rw for its memory # vault: # L10; memory/nisse-review rw for its memory
# root: ~/vault # root: ~/vault
+79
View File
@@ -0,0 +1,79 @@
"""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``). 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: 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)}
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
+14 -18
View File
@@ -15,6 +15,7 @@ from nisse_core.models.context import BudgetedClient
from nisse_core.models.llm import LiteLLMClient from nisse_core.models.llm import LiteLLMClient
from nisse_core.oplog import OpLog from nisse_core.oplog import OpLog
from nisse_core.state.cancel import Cancel from nisse_core.state.cancel import Cancel
from nisse_review.access import AccessRefused, check, reviewed
from nisse_review.config import ReviewConfig from nisse_review.config import ReviewConfig
from nisse_review.review import Reviewer from nisse_review.review import Reviewer
from nisse_review.scheduler import Scheduler from nisse_review.scheduler import Scheduler
@@ -41,23 +42,18 @@ class Built:
self.store.close() self.store.close()
def check_access(gitea: GiteaClient) -> tuple[str, list[str]]: def check_access(
"""Who it is and what it reads (R3): read only. An account that can push gitea: GiteaClient, patterns: frozenset[str] | None, oplog: OpLog
to, or administer, a repository it reviews is refused: its hard wall is ) -> tuple[str, list[str]]:
that it can't change what it reviews.""" """Who it is and what it reviews (R1, R3): read only. An account that
can push to, or administer, a repository it reviews is refused: its hard
wall is that it can't change what it reviews. The scheduler checks again
every round (``access``)."""
account = gitea.whoami() account = gitea.whoami()
repos = gitea.readable_repositories() try:
too_much = [ repos = check(gitea, reviewed(gitea, patterns, oplog), oplog)
repo except AccessRefused as exc:
for repo in repos raise StartupError(str(exc)) from exc
if (perms := gitea.repository(repo).get("permissions") or {}).get("push")
or perms.get("admin")
]
if too_much:
raise StartupError(
f"{account} can write to {', '.join(too_much)}: nisse-review's "
"account must have read access only (Part R, R3)"
)
return account, repos return account, repos
@@ -100,7 +96,7 @@ def build(
) -> Built: ) -> Built:
oplog = oplog or OpLog(AGENT) oplog = oplog or OpLog(AGENT)
gitea = gitea or GiteaClient(config.gitea_url, config.gitea_token) gitea = gitea or GiteaClient(config.gitea_url, config.gitea_token)
account, repos = check_access(gitea) account, repos = check_access(gitea, config.repositories, oplog)
client = client if client is not None else llm_client(config) client = client if client is not None else llm_client(config)
config.state_db.parent.mkdir(parents=True, exist_ok=True) config.state_db.parent.mkdir(parents=True, exist_ok=True)
store = StateStore(config.state_db, oplog=oplog) store = StateStore(config.state_db, oplog=oplog)
@@ -146,6 +142,6 @@ def build(
scratch=Path(scratch), scratch=Path(scratch),
poll_s=tuning.review.poll_s, poll_s=tuning.review.poll_s,
attempts=tuning.review.attempts, attempts=tuning.review.attempts,
only=config.repositories, patterns=config.repositories,
) )
return Built(scheduler, account, repos, store, gitea) return Built(scheduler, account, repos, store, gitea)
+8 -5
View File
@@ -43,8 +43,8 @@ REQUIRED_KEYS = (
OPTIONAL_KEYS = ( OPTIONAL_KEYS = (
"llm.max_output_tokens", "llm.max_output_tokens",
"llm.request_timeout_s", "llm.request_timeout_s",
# Only these of the repositories it can read; unset, every one (R1). # Patterns of the repositories it reviews ("dev/nisse", "dev/*", "*/*"),
# For tests and a first bring-up. # public ones included; unset, every one it was given (R1, ``access``).
"repositories", "repositories",
) )
@@ -132,6 +132,11 @@ class ReviewConfig:
if missing: if missing:
errors.append("unset or empty — " + ", ".join(missing)) errors.append("unset or empty — " + ", ".join(missing))
max_output, timeout = llm_numbers(values, errors) max_output, timeout = llm_numbers(values, errors)
patterns = people(values["repositories"]) if "repositories" in values else None
for pattern in sorted(patterns or ()):
owner, slash, name = pattern.partition("/")
if not (owner and slash and name) or "/" in name:
errors.append(f"repositories: {pattern!r} is not owner/name")
if errors: if errors:
raise ConfigError("cannot start: " + "; ".join(errors)) raise ConfigError("cannot start: " + "; ".join(errors))
reviewer = str(values["roles.reviewer"]) reviewer = str(values["roles.reviewer"])
@@ -149,7 +154,5 @@ class ReviewConfig:
request_timeout_s=timeout, request_timeout_s=timeout,
tuning=tuning, tuning=tuning,
vault=vault, vault=vault,
repositories=( repositories=patterns,
people(values["repositories"]) if "repositories" in values else None
),
) )
+5
View File
@@ -9,6 +9,7 @@ from types import FrameType
from nisse_core.config import ConfigError from nisse_core.config import ConfigError
from nisse_core.envfile import load_env_file from nisse_core.envfile import load_env_file
from nisse_review.access import AccessRefused
from nisse_review.build import StartupError, build from nisse_review.build import StartupError, build
from nisse_review.config import ReviewConfig from nisse_review.config import ReviewConfig
@@ -35,6 +36,10 @@ def main() -> int:
) )
try: try:
built.scheduler.run_forever() built.scheduler.run_forever()
except AccessRefused as exc:
# R3: a grant made while it ran. It stops rather than review on.
print(f"nisse-review: stopping: {exc}", file=sys.stderr)
return 3
finally: finally:
built.close() built.close()
return 0 return 0
+19 -10
View File
@@ -1,7 +1,7 @@
"""nisse-review's scheduler (R1): its one duty, and its items one at a time. """nisse-review's scheduler (R1): its one duty, and its items one at a time.
Every round, it asks Gitea for the open PRs in every repository its account Every round, it works out the repositories it reviews and checks it still
can read. A PR that is ready (not a draft, not marked work in progress) gets can't write to any (``access``), then asks Gitea for their open PRs. A PR that is ready (not a draft, not marked work in progress) gets
a work item; the oldest ready item is reviewed, and its review posted. An a work item; the oldest ready item is reviewed, and its review posted. An
item closes when its PR merges or closes. Later reviews, replies and rounds item closes when its PR merges or closes. Later reviews, replies and rounds
come with M7.5. come with M7.5.
@@ -19,6 +19,7 @@ from pathlib import Path
from nisse_core.gitea import GiteaClient, GiteaError, PullRequest from nisse_core.gitea import GiteaClient, GiteaError, PullRequest
from nisse_core.oplog import OpLog from nisse_core.oplog import OpLog
from nisse_core.state.store import WorkItem from nisse_core.state.store import WorkItem
from nisse_review.access import check, reviewed
from nisse_review.reader import Repository from nisse_review.reader import Repository
from nisse_review.review import Reviewer from nisse_review.review import Reviewer
from nisse_review.state import ( from nisse_review.state import (
@@ -54,7 +55,7 @@ class Scheduler:
scratch: Path, scratch: Path,
poll_s: float = 60.0, poll_s: float = 60.0,
attempts: int = 2, attempts: int = 2,
only: frozenset[str] | None = None, patterns: frozenset[str] | None = None,
) -> None: ) -> None:
self._store = store self._store = store
self._gitea = gitea self._gitea = gitea
@@ -63,7 +64,8 @@ class Scheduler:
self._scratch = scratch self._scratch = scratch
self._poll_s = poll_s self._poll_s = poll_s
self._attempts = attempts self._attempts = attempts
self._only = only # the repositories reviewed; None: every readable one # The repositories reviewed, as patterns; None: every one it was given.
self._patterns = patterns
self._stop = threading.Event() self._stop = threading.Event()
# -- the loop --------------------------------------------------------------- # -- the loop ---------------------------------------------------------------
@@ -77,10 +79,17 @@ class Scheduler:
self._stop.set() self._stop.set()
def round(self) -> bool: def round(self) -> bool:
"""Intake, then one review if one is ready; True if one ran.""" """The wall, intake, then one review if one is ready; True if one
ran. An account that can write to a repository it reviews raises
``AccessRefused``: it stops, never reviews on (R3)."""
try: try:
repos = check(
self._gitea,
reviewed(self._gitea, self._patterns, self._oplog),
self._oplog,
)
self.recover() self.recover()
self.intake() self.intake(repos)
except GiteaError as exc: except GiteaError as exc:
self._oplog.emit("intake.failed", cause=str(exc)[:300]) self._oplog.emit("intake.failed", cause=str(exc)[:300])
return False return False
@@ -92,11 +101,11 @@ class Scheduler:
# -- intake (R1) ------------------------------------------------------------ # -- intake (R1) ------------------------------------------------------------
def intake(self) -> None: def intake(self, repos: list[str] | None = None) -> None:
if repos is None:
repos = reviewed(self._gitea, self._patterns, self._oplog)
open_items = {i.id: i for i in self._store.work_items("open")} open_items = {i.id: i for i in self._store.work_items("open")}
for repo in self._gitea.readable_repositories(): for repo in repos:
if self._only is not None and repo not in self._only:
continue
seen: set[str] = set() seen: set[str] = set()
for pr in self._gitea.open_pull_requests(repo): for pr in self._gitea.open_pull_requests(repo):
key = item_id(repo, pr.number) key = item_id(repo, pr.number)
+17
View File
@@ -8,6 +8,7 @@ from typing import Any
from nisse_core.gitea import ( from nisse_core.gitea import (
Comment, Comment,
GiteaError, GiteaError,
GiteaNotFound,
Issue, Issue,
LineComment, LineComment,
PullRequest, PullRequest,
@@ -69,6 +70,9 @@ class FakeGitea:
comments: list[tuple[str, int, str]] = field(default_factory=list) comments: list[tuple[str, int, str]] = field(default_factory=list)
refuse_off_diff: bool = False refuse_off_diff: bool = False
down: 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.
public: set[str] = field(default_factory=set)
def _up(self) -> None: def _up(self) -> None:
if self.down: if self.down:
@@ -78,10 +82,23 @@ class FakeGitea:
return self.account return self.account
def readable_repositories(self) -> list[str]: def readable_repositories(self) -> list[str]:
self._up()
return [r for r in self.repos if r not in self.public]
def visible_repositories(self) -> list[str]:
self._up() self._up()
return list(self.repos) 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]: def repository(self, repo: str) -> dict[str, Any]:
if repo not in self.repos:
raise GiteaNotFound(f"no repository {repo}")
return { return {
"permissions": self.permissions.get( "permissions": self.permissions.get(
repo, {"pull": True, "push": False, "admin": False} repo, {"pull": True, "push": False, "admin": False}
+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.log.canonical import AssistantText
from nisse_core.loop.driver import Driver from nisse_core.loop.driver import Driver
from nisse_core.oplog import OpLog 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.build import StartupError, check_access
from nisse_review.review import Reviewer, _diff_lines, marker from nisse_review.review import Reviewer, _diff_lines, marker
from nisse_review.scheduler import Scheduler, ready 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).position, item(w, 8).position) == (READY, WAITING)
assert item(w, 7).scope == REPO assert item(w, 7).scope == REPO
gitea.repos["o/elsewhere"] = [pr(9)] gitea.repos["o/elsewhere"] = [pr(9)]
w["scheduler"]._only = frozenset({REPO}) w["scheduler"]._patterns = frozenset({REPO})
w["scheduler"].intake() w["scheduler"].intake()
assert w["store"].work_item("o/elsewhere#9") is None assert w["store"].work_item("o/elsewhere#9") is None
gitea.repos[REPO] = [pr(8, draft=False)] # 7 merged; 8 is ready now 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: def test_an_account_that_can_write_is_refused(tmp_path: Path) -> None:
gitea = FakeGitea(repos={REPO: [], "o/other": []}) 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} gitea.permissions["o/other"] = {"pull": True, "push": True}
with pytest.raises(StartupError, match="read access only"): 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: def test_diff_lines_are_the_heads_lines_a_diff_shows() -> None: