refactor: split opencode runtime and tests
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Configuration model for opencode review lenses."""
|
||||
|
||||
import dataclasses as _dc
|
||||
import os
|
||||
import re
|
||||
|
||||
_LENS_ID_RE = re.compile(r"^[a-z0-9-]{1,32}$")
|
||||
SEVERITY_ORDER = ("low", "medium", "high", "critical")
|
||||
|
||||
@_dc.dataclass(frozen=True)
|
||||
class ReviewerSpec:
|
||||
"""One lens to run. Immutable — synthesized from config once per review."""
|
||||
|
||||
id: str
|
||||
agent_file: str = "" # default derived from id below
|
||||
model: str = "" # default = the global OPENCODE_MODEL
|
||||
severity_floor: str = "low" # findings below are dropped
|
||||
max_findings: int = 12 # per-lens cap before synthesis
|
||||
activation: str = "auto" # auto | always | off (off = exclude entirely)
|
||||
skip_if_all_changed_paths: str = "" # glob; skip when every changed path matches
|
||||
hotpath_globs: tuple[str, ...] = () # for triage hint only
|
||||
|
||||
def agent_path(self, factory_root: str) -> str:
|
||||
"""Resolve the absolute path of this lens's agent markdown."""
|
||||
rel = self.agent_file or f".opencode/agents/{self.id}.md"
|
||||
return os.path.join(factory_root, rel)
|
||||
|
||||
|
||||
def _coerce_str(v, default: str = "") -> str:
|
||||
return str(v).strip() if isinstance(v, (str, int, float)) else default
|
||||
|
||||
|
||||
def _coerce_int(v, default: int, lo: int, hi: int) -> int:
|
||||
try:
|
||||
n = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(lo, min(hi, n))
|
||||
|
||||
|
||||
def default_reviewers() -> list[ReviewerSpec]:
|
||||
"""The 5-lens default when the repo's `.pr-review.json:reviewers[]` is absent.
|
||||
|
||||
Order matters: the synthesizer dedups by posthash and keeps the highest
|
||||
severity; on tie, the FIRST-listed lens wins. So security first (most
|
||||
conservative severity), then docs (additive), then code-quality + tests +
|
||||
perf (additive).
|
||||
"""
|
||||
return [
|
||||
ReviewerSpec(id="security", severity_floor="low", max_findings=12),
|
||||
ReviewerSpec(id="docs", severity_floor="low", max_findings=8),
|
||||
ReviewerSpec(id="code-quality", severity_floor="low", max_findings=8),
|
||||
ReviewerSpec(id="tests", severity_floor="low", max_findings=8),
|
||||
ReviewerSpec(id="perf", severity_floor="medium", max_findings=6),
|
||||
]
|
||||
|
||||
|
||||
def _coerce_str(v, default: str = "") -> str:
|
||||
return str(v).strip() if isinstance(v, (str, int, float)) else default
|
||||
|
||||
|
||||
def _coerce_int(v, default: int, lo: int, hi: int) -> int:
|
||||
try:
|
||||
n = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(lo, min(hi, n))
|
||||
|
||||
|
||||
def parse_reviewers_config(raw: dict) -> list[ReviewerSpec]:
|
||||
"""Read `.pr-review.json:reviewers[]` into `list[ReviewerSpec]`.
|
||||
|
||||
Validates: id (kebab ≤ 32 chars), model (must contain `/` — provider/model
|
||||
ref form), severity_floor ∈ SEVERITY_ORDER, max_findings ∈ [1..30],
|
||||
activation ∈ {auto,always,off}, skip_if is a string. Drops invalid entries
|
||||
silently. Caps the array at 8.
|
||||
|
||||
Returns [] on absent/invalid; the caller falls back to `default_reviewers()`.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[ReviewerSpec] = []
|
||||
for entry in raw[:8]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
rid = _coerce_str(entry.get("id", "")).lower()
|
||||
if not _LENS_ID_RE.match(rid):
|
||||
continue
|
||||
model = _coerce_str(entry.get("model", ""))
|
||||
if model and "/" not in model:
|
||||
model = "" # must be provider/model — silent drop of bad model
|
||||
sf = _coerce_str(entry.get("severity_floor", "")).lower()
|
||||
if sf not in SEVERITY_ORDER:
|
||||
sf = "low"
|
||||
mf = _coerce_int(entry.get("max_findings"), default=12, lo=1, hi=30)
|
||||
act = _coerce_str(entry.get("activation", "auto")).lower()
|
||||
if act not in ("auto", "always", "off"):
|
||||
act = "auto"
|
||||
skip = _coerce_str(entry.get("skip_if_all_changed_paths", ""))
|
||||
hot = entry.get("hotpath_globs") or []
|
||||
if isinstance(hot, list):
|
||||
hot = tuple(_coerce_str(g) for g in hot if _coerce_str(g))[:8]
|
||||
else:
|
||||
hot = ()
|
||||
out.append(ReviewerSpec(
|
||||
id=rid,
|
||||
agent_file=_coerce_str(entry.get("agent_file", "")),
|
||||
model=model,
|
||||
severity_floor=sf,
|
||||
max_findings=mf,
|
||||
activation=act,
|
||||
skip_if_all_changed_paths=skip,
|
||||
hotpath_globs=hot,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def parse_triage_config(raw: dict) -> dict:
|
||||
"""`.pr-review.json:triage` → safe defaults. Always returns a dict."""
|
||||
if not isinstance(raw, dict):
|
||||
return {"enabled": True, "model": "", "max_lenses": 5}
|
||||
enabled = bool(raw.get("enabled", True))
|
||||
model = _coerce_str(raw.get("model", ""))
|
||||
max_lenses = _coerce_int(raw.get("max_lenses"), default=5, lo=1, hi=8)
|
||||
return {"enabled": enabled, "model": model, "max_lenses": max_lenses}
|
||||
|
||||
|
||||
def resolve_reviewers(config: dict | None) -> list[ReviewerSpec]:
|
||||
"""Pick the reviewer list: config-driven if present, else defaults.
|
||||
|
||||
Drops `activation: off` entries (they're config noise). The triage step
|
||||
further filters by surface.
|
||||
"""
|
||||
cfg = config or {}
|
||||
raw = cfg.get("reviewers")
|
||||
parsed = parse_reviewers_config(raw) if raw is not None else []
|
||||
base = parsed if parsed else default_reviewers()
|
||||
return [r for r in base if r.activation != "off"]
|
||||
Reference in New Issue
Block a user