M7.3: nisse-review's first slice
A new package over nisse-core: it reviews every ready PR in the repositories its account can read, and posts one COMMENT review. - scheduler: each round, open PRs per readable repository (or the configured repositories); an item per PR, ready unless draft or WIP; closed with the PR. One review at a time, oldest first. - reader: the repository at head and base from Gitea archives, unpacked and read in Python (read_file, list_files, search_code), and its issues; bound to the item's repository; no git, no shell. - review: starts from the PR, commits, diff, comments, closed issues, CI; comment drafts a point on any existing line; post_review posts one COMMENT review at the head, inline where Gitea takes it, else as links in the summary. ask_expert; vault and memory (memory/nisse-review). - L8: posting is an intent; recovery asks Gitea for the marked review and never posts twice; with Gitea down, the item waits. - Failures retried, then said on the PR until the next push. - Startup refuses an account that can push to or administer any repo. - nisse-core gitea: readable_repositories, open_pull_requests, diff, commits, archive, issue, search_issues, reviews, post_review; PR fields. - Bring-up: scripts/review-bringup.sh; nisse-review.example.yaml. - Tests: unit over a fake Gitea and scripted model; a live test (NISSE_REVIEW_GITEA_TOKEN) reviews a PR in a fresh test repository. - Docs: Development Plan M7.3, Tuning Guide (tuning.review.*). 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:
1 parent
fc71d3da75
commit
f59d974a95
24 files changed
+2330
-7
No files matched your search
@@ -0,0 +1,151 @@
|
||||
"""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.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) -> tuple[str, list[str]]:
|
||||
"""Who it is and what it reads (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."""
|
||||
account = gitea.whoami()
|
||||
repos = gitea.readable_repositories()
|
||||
too_much = [
|
||||
repo
|
||||
for repo in repos
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
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,
|
||||
only=config.repositories,
|
||||
)
|
||||
return Built(scheduler, account, repos, store, gitea)
|
||||
Reference in new issue
Block a user