feat(review): multi-lens orchestration — 5 parallel opencode subprocesses (security/docs/code-quality/tests/perf)

On by default, opt-out via "reviewers": []. 290 tests pass.

- pilot/opencode_review.py: ReviewerSpec dataclass, default_reviewers(),
  parse_reviewers_config(), parse_triage_config(), resolve_reviewers(),
  _normalize_lens_finding(), posthash() (matches feedback.py scheme),
  _agreement_hash() (severity-free for cross-lens promotion), _tone_strip(),
  synthesize() 7-stage (severity_floor → tone-strip → length cap → per-lens
  max → per-file cap → dedup by _posthash → cross-lens severity promote →
  per-PR cap), run_lenses() (ThreadPoolExecutor pool=4), triage(),
  _intersect_with_triage(), _filter_by_skip_if(), run_lenses_review().
  run() routes to fan-out when config.reviewers[] present or PRAGENT_REVIEWERS=1.
- pilot/ai_review.py: parse_repo_config learns reviewers[] and triage objects
  (id regex /^[a-z0-9][a-z0-9-]{0,31}$/, 8-entry cap, agent_file/model/
  severity_floor/max_findings/activation/skip_if_all_changed_paths/hotpath_globs).
  review_pr branches to opencode_review.run_lenses_review when configured.
  _render_collapsible_usage shows lenses: ... line when present.
- .opencode/agents/{docs,code-quality,triage}.md: 3 new lens subagents.
- .opencode/skills/lens-orchestration/SKILL.md: strict-JSON contract every
  lens subagent MUST honor.
- .opencode/agents/pragent.md: slim to coordinator; no more hardcoded
  @security/@tests/@perf delegation; loads lens-orchestration skill.
- .opencode/README.md: rewrite 'Add a review lens' recipe for multi-lens.
- pilot/README-webhook.md: new 'Multi-lens pipeline' section (diagram +
  default roster + config schema + env vars + cross-lens dedup contract).
- tests: 36 new tests (test_ai_review.py +12 reviewers/triage/usage,
  test_opencode_review.py +24 orchestration). posthash golden-vector matches
  feedback.py exactly across 5 severity × 2 line cases.
This commit is contained in:
pragent-bot
2026-08-20 22:16:21 +00:00
parent e8ebc54362
commit 78bcf6a9a0
11 changed files with 1847 additions and 51 deletions
+135
View File
@@ -1458,3 +1458,138 @@ def test_build_user_prompt_injects_additional_context():
def test_build_user_prompt_skips_additional_context_when_empty():
prompt = build_user_prompt("T", "B", "diff")
assert "## Repo-provided context" not in prompt
# ---------------------------------------------------------------------------
# parse_repo_config: reviewers[] + triage (multi-lens orchestration)
# ---------------------------------------------------------------------------
def test_parse_repo_config_reviewers_array_basic():
raw = json.dumps({
"reviewers": [
{"id": "security", "severity_floor": "high", "max_findings": 10},
{"id": "docs", "agent_file": ".opencode/agents/docs.md"},
{"id": "perf", "model": "headroom/glm-5.2:cloud",
"skip_if_all_changed_paths": "docs/**"},
]
})
cfg = parse_repo_config(raw)
assert cfg["reviewers"] == [
{"id": "security", "severity_floor": "high", "max_findings": 10},
{"id": "docs", "agent_file": ".opencode/agents/docs.md"},
{"id": "perf", "model": "headroom/glm-5.2:cloud",
"skip_if_all_changed_paths": "docs/**"},
]
def test_parse_repo_config_reviewers_rejects_bad_id():
# Punctuation, leading dash, underscore, empty — all silently dropped.
cfg = parse_repo_config(json.dumps({
"reviewers": [
{"id": "BAD!!!"},
{"id": "-bad-start"},
{"id": "ok_under"},
{"id": ""},
{"id": "good-one"},
]
}))
assert cfg["reviewers"] == [{"id": "good-one"}]
def test_parse_repo_config_reviewers_caps_at_8():
cfg = parse_repo_config(json.dumps({
"reviewers": [{"id": f"l{i}"} for i in range(12)]
}))
assert len(cfg["reviewers"]) == 8
def test_parse_repo_config_reviewers_drop_non_dict_entries():
cfg = parse_repo_config(json.dumps({
"reviewers": ["not-a-dict", 42, None, {"id": "ok"}]
}))
assert cfg["reviewers"] == [{"id": "ok"}]
def test_parse_repo_config_reviewers_absent_yields_no_key():
cfg = parse_repo_config("{}")
assert "reviewers" not in cfg
def test_parse_repo_config_reviewers_activation_validated():
cfg = parse_repo_config(json.dumps({
"reviewers": [
{"id": "a", "activation": "auto"},
{"id": "b", "activation": "always"},
{"id": "c", "activation": "off"},
{"id": "d", "activation": "BOGUS"}, # dropped (unsupported)
]
}))
# Only the entries with valid activation carry the key — the BOGUS one
# just keeps id (the unknown field is silently dropped, not rejected).
assert [r.get("activation") for r in cfg["reviewers"]] == [
"auto", "always", "off", None
]
def test_parse_repo_config_triage_object_full():
cfg = parse_repo_config(json.dumps({
"triage": {"enabled": True, "model": "headroom/haiku", "max_lenses": 3}
}))
assert cfg["triage"] == {"enabled": True, "model": "headroom/haiku", "max_lenses": 3}
def test_parse_repo_config_triage_disabled():
cfg = parse_repo_config(json.dumps({"triage": {"enabled": False}}))
assert cfg["triage"] == {"enabled": False}
def test_parse_repo_config_triage_malformed_yields_disabled():
# Non-object triage value (string, list, number) should disable, not crash.
for raw in (
'{"triage": "off"}',
'{"triage": []}',
'{"triage": 42}',
):
cfg = parse_repo_config(raw)
assert cfg.get("triage") == {"enabled": False}, f"failed for {raw}"
def test_parse_repo_config_triage_absent_yields_no_key():
cfg = parse_repo_config("{}")
assert "triage" not in cfg
def test_parse_repo_config_triage_max_lenses_capped_at_8():
cfg = parse_repo_config(json.dumps({"triage": {"max_lenses": 100}}))
# 100 is out of range; the key is dropped, not clamped. Caller defaults.
assert "max_lenses" not in cfg.get("triage", {})
def test_render_collapsible_usage_shows_lenses_when_multi():
usage = {
"input": 100, "output": 50, "reasoning": 0,
"cache_read": 0, "cache_write": 0, "total": 150,
"steps": 12, "duration_s": 8.4,
"lenses": ["security", "docs", "tests"],
"lens_steps": 12,
}
out = _render_collapsible_usage(usage, "headroom/glm-5.2:cloud", None)
assert "Lenses" in out
# All three lens ids are shown in backticks.
assert "`security`" in out
assert "`docs`" in out
assert "`tests`" in out
# Step count is surfaced.
assert "12" in out
def test_render_collapsible_usage_omits_lenses_when_single_primary():
usage = {
"input": 100, "output": 50, "reasoning": 0,
"cache_read": 0, "cache_write": 0, "total": 150,
"steps": 4, "duration_s": 2.0,
}
out = _render_collapsible_usage(usage, "headroom/glm-5.2:cloud", None)
assert "Lenses" not in out