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,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
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user