M7.3: nisse-review's first slice
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m49s
CI / e2e (pull_request) Successful in 3m18s

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:
dongarandClaude Opus 5.5 committed 2026-10-13 22:34:02 +00:00
1 parent fc71d3da75
commit f59d974a95
24 files changed
+2330 -7

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
"""nisse-review's configuration (architecture L9: nothing hardcoded; R5).
A YAML file holding no secrets, built from ``nisse_core.config``: the LLM and
its roles, Gitea, the state store, the vault, and tuning. Credentials are
``${VAR}`` references resolved from the environment.
"""
import os
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from nisse_core.config import (
ConfigError,
VaultConfig,
flatten,
llm_numbers,
parse_tuning,
parse_vault,
people,
read_file,
resolve,
)
from nisse_core.models.llm import (
DEFAULT_MAX_OUTPUT_TOKENS,
DEFAULT_REQUEST_TIMEOUT_S,
RoleMap,
)
DEFAULT_CONFIG_FILE = "nisse-review.yaml"
REQUIRED_KEYS = (
"state_db",
"llm.base_url",
"llm.token",
"roles.reviewer",
"roles.expert",
"gitea.url",
"gitea.token",
"gitea.operators",
)
OPTIONAL_KEYS = (
"llm.max_output_tokens",
"llm.request_timeout_s",
# Only these of the repositories it can read; unset, every one (R1).
# For tests and a first bring-up.
"repositories",
)
@dataclass(frozen=True)
class ReviewTuning:
poll_s: float = 60.0 # how often Gitea is asked for ready PRs
max_turns: int = 40 # a review run's turn cap
diff_chars: int = 60000 # the diff in the review's context; files on demand
attempts: int = 2 # failed runs on one PR before saying so on it
@dataclass(frozen=True)
class ExpertTuning:
consult_budget: int = 3
@dataclass(frozen=True)
class ContextTuning:
keep_recent_turns: int = 6
safety_margin: float = 0.1
@dataclass(frozen=True)
class Tuning:
review: ReviewTuning = field(default_factory=ReviewTuning)
expert: ExpertTuning = field(default_factory=ExpertTuning)
context: ContextTuning = field(default_factory=ContextTuning)
@dataclass(frozen=True)
class ReviewConfig:
state_db: Path
llm_base_url: str
llm_token: str = field(repr=False)
# The reviewer drives (R5); the helper role isn't used, so it is the
# reviewer's model too.
roles: RoleMap
gitea_url: str
gitea_token: str = field(repr=False)
gitea_operators: frozenset[str] # whose comments are the operator's (R4)
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
tuning: Tuning = field(default_factory=Tuning)
vault: VaultConfig | None = None
repositories: frozenset[str] | None = None # None: all it can read
@classmethod
def load(cls, env: Mapping[str, str] = os.environ) -> "ReviewConfig":
"""The file named by NISSE_CONFIG, or ./nisse-review.yaml."""
path = Path(env.get("NISSE_CONFIG") or DEFAULT_CONFIG_FILE).expanduser()
if not path.is_file():
raise ConfigError(f"cannot start: no config file at {path}")
errors: list[str] = []
return cls.from_tree(read_file(path, errors), env, errors)
@classmethod
def from_tree(
cls,
tree: Mapping[str, Any],
env: Mapping[str, str] = os.environ,
errors: list[str] | None = None,
) -> "ReviewConfig":
errors = [] if errors is None else errors
tuning = parse_tuning(tree.get("tuning"), Tuning, errors)
if tuning.context.safety_margin >= 1:
errors.append("tuning.context.safety_margin: must be less than 1")
vault = parse_vault(tree.get("vault"), errors)
flat = flatten({k: v for k, v in tree.items() if k != "vault"})
flat.pop("tuning", None)
known = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS)
errors += [f"{key}: unknown setting" for key in sorted(set(flat) - known)]
values = {
key: resolve(value, key, env, errors)
for key, value in flat.items()
if key in known
}
missing = [
key
for key in REQUIRED_KEYS
if values.get(key) is None
or (isinstance(values[key], str) and not values[key].strip())
or (isinstance(values[key], list) and not people(values[key]))
]
if missing:
errors.append("unset or empty — " + ", ".join(missing))
max_output, timeout = llm_numbers(values, errors)
if errors:
raise ConfigError("cannot start: " + "; ".join(errors))
reviewer = str(values["roles.reviewer"])
return cls(
state_db=Path(values["state_db"]).expanduser(),
llm_base_url=str(values["llm.base_url"]),
llm_token=str(values["llm.token"]),
roles=RoleMap(
driver=reviewer, helper=reviewer, expert=str(values["roles.expert"])
),
gitea_url=str(values["gitea.url"]),
gitea_token=str(values["gitea.token"]),
gitea_operators=people(values["gitea.operators"]),
max_output_tokens=max_output,
request_timeout_s=timeout,
tuning=tuning,
vault=vault,
repositories=(
people(values["repositories"]) if "repositories" in values else None
),
)