Files
nisse/nisse-review/src/nisse_review/build.py
T
dongarandClaude Opus 5.5 2139ddd05c
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m56s
CI / e2e (pull_request) Successful in 9m52s
nisse-review: repository patterns, and the wall checked every round
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
2026-10-16 21:41:53 +00:00

148 lines
4.8 KiB
Python

"""Composition root: how nisse-review fits together, and the checks it makes
before it reviews anything."""
from dataclasses import dataclass
from pathlib import Path
from nisse_core.gitea import GiteaClient
from nisse_core.knowledge.handlers import Knowledge
from nisse_core.knowledge.memory import Memory
from nisse_core.knowledge.vault import Vault
from nisse_core.loop.driver import Driver
from nisse_core.loop.expert import ExpertDesk
from nisse_core.models.catalog import ModelCatalog
from nisse_core.models.context import BudgetedClient
from nisse_core.models.llm import LiteLLMClient
from nisse_core.oplog import OpLog
from nisse_core.state.cancel import Cancel
from nisse_review.access import AccessRefused, check, reviewed
from nisse_review.config import ReviewConfig
from nisse_review.review import Reviewer
from nisse_review.scheduler import Scheduler
from nisse_review.state import StateStore
AGENT = "nisse-review"
MEMORY_FOLDER = "memory/nisse-review" # L10: its own, in the vault
class StartupError(RuntimeError):
pass
@dataclass
class Built:
scheduler: Scheduler
account: str
repositories: list[str]
store: StateStore
gitea: GiteaClient
def close(self) -> None:
self.gitea.close()
self.store.close()
def check_access(
gitea: GiteaClient, patterns: frozenset[str] | None, oplog: OpLog
) -> tuple[str, list[str]]:
"""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()
try:
repos = check(gitea, reviewed(gitea, patterns, oplog), oplog)
except AccessRefused as exc:
raise StartupError(str(exc)) from exc
return account, repos
def llm_client(config: ReviewConfig) -> BudgetedClient:
"""The one route to the models, every request fitted to its window
(L6), against what LiteLLM says the configured models are (L9)."""
catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token)
pinned = config.roles.pinned()
del pinned["classifier"] # not one of nisse-review's roles
catalog.validate(pinned)
catalog.require_limits(pinned)
def limits_for(model: str) -> tuple[int, int | None]:
facts = catalog.facts(model)
assert facts.max_input_tokens is not None # require_limits checked
return facts.max_input_tokens, facts.max_output_tokens
tuning = config.tuning.context
return BudgetedClient(
LiteLLMClient(
config.llm_base_url,
config.llm_token,
max_tokens=config.max_output_tokens,
timeout_s=config.request_timeout_s,
),
limits_for,
max_output_tokens=config.max_output_tokens,
keep_recent_turns=tuning.keep_recent_turns,
safety_margin=tuning.safety_margin,
enforces_tool_choice=lambda m: catalog.facts(m).supports_tool_choice is True,
)
def build(
config: ReviewConfig,
*,
gitea: GiteaClient | None = None,
client: object | None = None,
oplog: OpLog | None = None,
) -> Built:
oplog = oplog or OpLog(AGENT)
gitea = gitea or GiteaClient(config.gitea_url, config.gitea_token)
account, repos = check_access(gitea, config.repositories, oplog)
client = client if client is not None else llm_client(config)
config.state_db.parent.mkdir(parents=True, exist_ok=True)
store = StateStore(config.state_db, oplog=oplog)
vault = (
Vault(
config.vault.root,
config.vault.folders,
agent=AGENT,
ignore=config.vault.ignore,
)
if config.vault
else None
)
memory = (
Memory(vault, MEMORY_FOLDER)
if vault and config.vault and config.vault.folders.get(MEMORY_FOLDER) == "rw"
else None
)
tuning = config.tuning
reviewer = Reviewer(
store,
gitea,
Driver(client, config.roles.resolve("driver"), cancel=Cancel()), # type: ignore[arg-type]
account=account,
gitea_url=config.gitea_url,
oplog=oplog,
knowledge=Knowledge(vault, memory, oplog),
expert=ExpertDesk(
client, # type: ignore[arg-type]
config.roles,
budget=tuning.expert.consult_budget,
),
max_turns=tuning.review.max_turns,
diff_chars=tuning.review.diff_chars,
)
scratch = config.state_db.parent / "scratch"
scratch.mkdir(exist_ok=True)
scheduler = Scheduler(
store,
gitea,
reviewer,
oplog=oplog,
scratch=Path(scratch),
poll_s=tuning.review.poll_s,
attempts=tuning.review.attempts,
patterns=config.repositories,
)
return Built(scheduler, account, repos, store, gitea)