feat: add unified-review workflow (scoring tools + skill)

Adds the unified-review integration that fuses CRG graph context with the
ai-code-review scoring methodology and gstack-review fix-first workflow:

- scoring.py: objective Layer-2 metrics (sql_risk, exception_coverage,
  redundancy_rate, high_risk_density, vulnerability_risk) with
  good/warn/fail grades, plus dedupe_findings (fingerprint merge,
  multi-source confidence boost, PR quality score) and report data builder
- tools/scoring_tools.py + main.py: three new MCP tools
  (score_review_tool, dedupe_findings_tool, generate_report_tool)
- assets/report-template.html: self-contained HTML report template
- skills.py + skills/unified-review/: new read-only unified-review skill
  with language/manual-review/specialist checklists
- docs and CHANGELOG updated; tests added (test_scoring, test_report,
  test_unified_review) and test_skills updated for 5 skills
This commit is contained in:
dev
2026-08-05 13:31:55 +08:00
parent 82b7c6dc9e
commit 84ae9b817e
32 changed files with 2229 additions and 19 deletions
+15 -1
View File
@@ -1,6 +1,6 @@
"""MCP tool definitions for the Code Review Graph server.
Exposes 27 tools:
Exposes 31 tools:
1. build_or_update_graph - full or incremental build
2. get_impact_radius - blast radius from changed files
3. query_graph - predefined graph queries
@@ -29,6 +29,9 @@ Exposes 27 tools:
26. get_surprising_connections - find unexpected architectural coupling
27. get_suggested_questions - auto-generated review questions from graph analysis
28. traverse_graph - BFS/DFS traversal from best-matching node
29. score_review - objective Layer-2 review metrics for changed files
30. dedupe_findings - fingerprint dedup + confidence merge for findings
31. generate_report - render the standalone HTML code review report
"""
from __future__ import annotations
@@ -103,6 +106,13 @@ from .review import (
get_review_context,
)
# -- scoring (unified-review) ------------------------------------------------
from .scoring_tools import (
dedupe_findings_func,
generate_report_func,
score_review_func,
)
__all__ = [
# _common
"_BUILTIN_CALL_NAMES",
@@ -143,6 +153,10 @@ __all__ = [
"detect_changes_func",
"get_affected_flows_func",
"get_review_context",
# scoring (unified-review)
"score_review_func",
"dedupe_findings_func",
"generate_report_func",
# analysis_tools
"get_bridge_nodes_func",
"get_hub_nodes_func",
+5 -4
View File
@@ -115,8 +115,8 @@ def get_docs_section(
Args:
section_name: Exact section name. One of: usage, review-delta,
review-pr, commands, legal, watch, embeddings,
languages, troubleshooting.
review-pr, unified-review, score-review, commands,
legal, watch, embeddings, languages, troubleshooting.
repo_root: Repository root path. Auto-detected from current
directory if omitted.
@@ -177,8 +177,9 @@ def get_docs_section(
}
available = [
"usage", "review-delta", "review-pr", "commands",
"legal", "watch", "embeddings", "languages", "troubleshooting",
"usage", "review-delta", "review-pr", "unified-review",
"score-review", "commands", "legal", "watch", "embeddings",
"languages", "troubleshooting",
]
return {
"status": "not_found",
+269
View File
@@ -0,0 +1,269 @@
"""MCP tool wrappers for the unified-review scoring workflow.
Wraps :mod:`code_review_graph.scoring` (score_review / dedupe_findings /
build_report_data) into the three MCP tools consumed by the
``unified-review`` skill:
* ``score_review_tool`` - objective Layer-2 metrics for changed files
* ``dedupe_findings_tool`` - fingerprint dedup + confidence merge
* ``generate_report_tool`` - render ``code-review-report.html``
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from ..incremental import get_changed_files, get_staged_and_unstaged
from ..scoring import build_report_data, dedupe_findings, score_review
from ._common import _get_store, _error_response
try:
from importlib.resources import files as _pkg_files # Python 3.9+
_HAS_IMPORTLIB_RESOURCES = True
except ImportError: # pragma: no cover
_HAS_IMPORTLIB_RESOURCES = False
# ---------------------------------------------------------------------------
# Tool: score_review
# ---------------------------------------------------------------------------
def score_review_func(
changed_files: list[str] | None = None,
base: str = "HEAD~1",
include_churn: bool = True,
repo_root: str | None = None,
detail_level: str = "standard",
) -> dict[str, Any]:
"""Compute objective Layer-2 review metrics for changed files.
Runs the git-history / graph risk factors plus the heuristic metrics
(SQL risk, exception coverage, redundancy, high-risk density,
vulnerability). LLM-judged metrics are listed in ``llm_judged`` so the
calling agent knows what still needs judgement.
Args:
changed_files: Files to score (auto-detected from git diff if
omitted).
base: Git ref to diff against (default: HEAD~1).
include_churn: Include git-churn risk factors (default: True).
repo_root: Repository root. Auto-detected if omitted.
detail_level: Output detail level. ``minimal`` returns only grades
and values; ``standard`` includes evidence.
"""
store, root = _get_store(repo_root)
try:
if changed_files is None:
changed_files = get_changed_files(root, base)
if not changed_files:
changed_files = get_staged_and_unstaged(root)
if not changed_files:
return {
"status": "ok",
"summary": "No changed files detected. Nothing to score.",
"metrics": {},
"objective_grade": "good",
}
result = score_review(
store,
root,
changed_files,
include_churn=include_churn,
)
if detail_level == "minimal":
return {
"status": "ok",
"summary": result["summary"],
"objective_grade": result["objective_grade"],
"metrics": {
name: {"value": m["value"], "grade": m["grade"]}
for name, m in result["metrics"].items()
},
"llm_judged": result["llm_judged"],
}
result["changed_files"] = changed_files
result["next_tool_suggestions"] = [
"dedupe_findings -- merge specialist findings",
"detect_changes -- risk-scored impact analysis",
"generate_report -- export HTML report",
]
return result
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool: dedupe_findings
# ---------------------------------------------------------------------------
def dedupe_findings_func(
findings: list[dict[str, Any]],
suppress_prior: list[dict[str, Any]] | None = None,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Merge review findings by fingerprint and boost multi-source confidence.
Mirrors the gstack-review "collect and merge" step: findings with the
same ``path:line:category`` fingerprint are merged (highest confidence
wins); findings confirmed by more than one source get confidence +1
(cap 10). Confidence gates route low-confidence findings to the
appendix or suppress them, and a PR quality score is computed as
``max(0, 10 - (critical*2 + informational*0.5))``.
Args:
findings: Raw finding dicts with ``path``, ``category``,
``severity``, ``confidence`` and optional ``source``/``line``.
suppress_prior: Previously user-skipped findings (from a prior
review-log) to suppress when their file has not changed.
repo_root: Repository root (used to resolve changed files when
suppressing prior findings).
"""
try:
suppressed_prior_list: list[dict[str, Any]] = []
if suppress_prior:
for f in suppress_prior:
suppressed_prior_list.append(f)
result = dedupe_findings(findings, suppressed_prior_list)
result["next_tool_suggestions"] = [
"generate_report -- export HTML report",
"detect_changes -- risk-scored impact analysis",
]
return result
except Exception as exc:
return _error_response(str(exc))
# ---------------------------------------------------------------------------
# Tool: generate_report
# ---------------------------------------------------------------------------
def _load_report_template() -> str:
"""Load ``report-template.html`` from the installed package assets.
Falls back to the bundled template string when the asset cannot be
loaded, so the tool never fails solely because the package data is
missing.
"""
if _HAS_IMPORTLIB_RESOURCES:
try:
data = _pkg_files("code_review_graph").joinpath(
"assets/report-template.html"
).read_text(encoding="utf-8")
if data:
return data
except (FileNotFoundError, OSError):
pass
return _FALLBACK_TEMPLATE
# Minimal self-contained template (used if the asset file is unavailable).
_FALLBACK_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Code Review Report</title>
<style>
body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
margin:2rem auto;max-width:900px;color:#1f2328;line-height:1.5}
h1{font-size:1.5rem} .verdict{font-weight:700}
.pass{color:#1a7f37}.fail{color:#cf222e}
table{border-collapse:collapse;width:100%;margin:1rem 0}
th,td{border:1px solid #d0d7de;padding:.4rem .6rem;text-align:left;font-size:.9rem}
th{background:#f6f8fa}
.good{color:#1a7f37}.warn{color:#bf8700}.fail{color:#cf222e}.na{color:#57606a}
.issue{margin:.5rem 0;padding:.6rem;border-radius:6px;background:#f6f8fa}
.tag{font-weight:700;margin-right:.4rem}
.location{color:#57606a;font-size:.85rem}
</style>
</head>
<body>
<div id="report"></div>
<script>
const data = {{REPORT_DATA}};
const el = document.getElementById("report");
let html = `<h1>Code Review Report</h1>
<p><span class="verdict ${data.verdictClass || "fail"}">${data.verdict || "NO VERDICT"}</span>
&middot; Tier: ${data.tier || "standard"} &middot; Scope: ${data.scope || "change-level"}</p>`;
if (data.files) html += `<p><b>Files:</b> ${data.files}</p>`;
if (data.summary) html += `<p>${data.summary}</p>`;
html += `<h2>Objective Metrics</h2><table><tr><th>Metric</th><th>Value</th><th>Grade</th></tr>`;
for (const [k, m] of Object.entries(data.metrics || {})) {
html += `<tr><td>${k}</td><td>${m.value ?? "N/A"}</td>
<td class="${m.grade || "na"}">${m.grade || "N/A"}</td></tr>`;
}
html += `</table>`;
html += `<h2>Issues (${(data.issues || []).length})</h2>`;
for (const i of data.issues || []) {
html += `<div class="issue"><span class="tag ${i.severity}">${i.severity}</span>
<span>${i.message || ""}</span>
<div class="location">${i.location || ""}</div></div>`;
}
if (data.llm_judged && data.llm_judged.length) {
html += `<p><b>LLM-judged:</b> ${data.llm_judged.join(", ")}</p>`;
}
el.innerHTML = html;
</script>
</body>
</html>
"""
def generate_report_func(
review_data: dict[str, Any],
output_path: str | None = None,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Generate a self-contained HTML code review report.
Injects ``review_data`` (the output of ``score_review_tool`` plus
``dedupe_findings_tool`` results and free-form verdict/tier/scope) into
the bundled ``report-template.html`` as ``{{REPORT_DATA}}`` and writes
the standalone file (default ``repo_root/code-review-report.html``).
Args:
review_data: Review data dict (metrics, findings, verdict, tier,
scope, files, baseline, timestamp, manual_review).
output_path: Output file path. Defaults to
``<repo_root>/code-review-report.html``.
repo_root: Repository root. Auto-detected if omitted.
"""
store, root = _get_store(repo_root)
try:
data = build_report_data(review_data)
data["summary"] = review_data.get("summary", "")
template = _load_report_template()
rendered = template.replace(
"{{REPORT_DATA}}", json.dumps(data, ensure_ascii=False)
)
if output_path:
out = Path(output_path)
if not out.is_absolute():
out = root / out
else:
out = root / "code-review-report.html"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(rendered, encoding="utf-8")
return {
"status": "ok",
"summary": f"Report written to {out}",
"output_path": str(out),
"report_size_bytes": len(rendered),
}
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()