Files
nisse/nisse-review/src/nisse_review/build.py
T
dongarandClaude Opus 5.5 3813ad55d6
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m3s
CI / e2e (pull_request) Successful in 4m5s
nisse-review: read code from a git clone kept between runs
Archives were downloaded and unpacked afresh for every run, which doesn't
scale to very large repositories. Each repository is now cloned once, with
its whole history, under scratch/repos/<owner>/<name>/ (head/ the clone,
base/ a worktree when read). A run fetches every branch and the PR's
refs/pull/N/head, checks out the head exactly, and fails if that head is
gone. Only the latest repository is kept for now.

Git runs walled off: none of the host's git configuration, links checked
out as plain files, no hooks, submodules or LFS, only the Gitea URL's
protocol, and the token as an http.extraHeader for that URL in git's
environment only. A stop ends a git command; a cut-short first clone is
removed. The reader never shows .git. GiteaClient.archive is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-17 20:37:12 +00:00

169 lines
5.6 KiB
Python

"""Composition root: how nisse-review fits together, and the checks it makes
before it reviews anything."""
from dataclasses import dataclass
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.interruptible import Interruptible
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.clones import Clones
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
model: Interruptible
def stop(self) -> None:
"""From the signal handler: no more rounds, the run in progress
ends at its next turn, and a model call in flight is broken off."""
self.scheduler.stop()
self.model.interrupt()
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)
# Every model call, the reviewer's and ask_expert's, can be broken off.
model = Interruptible(client if client is not None else llm_client(config)) # type: ignore[arg-type]
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
cancel = Cancel()
reviewer = Reviewer(
store,
gitea,
Driver(model, 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(
model, # type: ignore[arg-type]
config.roles,
budget=tuning.expert.consult_budget,
),
max_turns=tuning.review.max_turns,
diff_chars=tuning.review.diff_chars,
)
# The repositories' clones, kept between runs (R3).
clones = Clones(
config.state_db.parent / "scratch" / "repos",
config.gitea_url,
config.gitea_token,
oplog=oplog,
cancel=cancel,
)
scheduler = Scheduler(
store,
gitea,
reviewer,
oplog=oplog,
sources=clones,
poll_s=tuning.review.poll_s,
attempts=tuning.review.attempts,
ci_wait_s=tuning.review.ci_wait_s,
ci_grace_s=tuning.review.ci_grace_s,
operators=config.gitea_operators,
max_rounds=tuning.review.max_rounds,
patterns=config.repositories,
cancel=cancel,
)
return Built(scheduler, account, repos, store, gitea, model)