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
148 lines
4.8 KiB
Python
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)
|