Files
code-review-graph/code_review_graph/tools/scoring_tools.py
T
dev 307d2fd471 feat: add project-review workflow (whole-project / single-feature review)
Adds the project-review workflow for code review independent of the git
diff. The scope is parsed from the user instruction: 全面/整个项目 ->
whole-project (score every source file), otherwise feature + target
keyword (locate the code with semantic search + graph queries).

- scoring_tools.py: score_review_func gains all_files=True to score every
  source file in the graph via store.get_all_files()
- main.py: score_review_tool gains all_files param; registers the
  project_review MCP prompt (prompts 6->7)
- prompts.py: project_review_prompt(scope, target) with whole-project and
  feature branches (fixed a precedence bug that truncated the feature text)
- skills.py + skills/project-review/: new read-only project-review skill
  with shared checklists
- .opencode/command/code-review-graph-project-review.md: slash command
- tests: test_project_review.py (prompt rendering), TestProjectReviewPrompt,
  skill count assertions 5->6, all_files wiring checks
- docs: prompts (6->7) + project-review entries across COMMANDS, CLAUDE,
  README (+localized), INDEX, architecture, LLM-OPTIMIZED-REFERENCE,
  CHANGELOG
2026-08-06 13:56:54 +08:00

313 lines
11 KiB
Python

"""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 the HTML and/or Markdown review report
"""
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,
render_markdown_report,
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",
all_files: bool = False,
) -> 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.
all_files: When True, score every source file in the graph,
ignoring ``changed_files`` and the git diff. Used for
whole-project reviews (default: False).
"""
store, root = _get_store(repo_root)
try:
if all_files:
changed_files = store.get_all_files()
elif 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,
format: str = "both",
) -> dict[str, Any]:
"""Generate code review reports (HTML and/or Markdown).
Renders ``review_data`` (the output of ``score_review_tool`` plus
``dedupe_findings_tool`` results and free-form verdict/tier/scope) into
a self-contained HTML report (``report-template.html``) and/or a
standalone Chinese Markdown report. Both are written by default.
``output_path`` is treated as the basename/directory of the output:
the extension is decided by ``format``, so ``both`` writes
``<base>.html`` and ``<base>.md``.
Args:
review_data: Review data dict (metrics, findings, verdict, tier,
scope, files, baseline, timestamp, manual_review).
output_path: Output base path (without an extension). Defaults to
``<repo_root>/code-review-report``.
repo_root: Repository root. Auto-detected if omitted.
format: Output format. ``html``, ``markdown``, or ``both``
(default: both).
"""
if format not in ("html", "markdown", "both"):
return _error_response(
f"Invalid format {format!r}; expected 'html', 'markdown' or 'both'."
)
store, root = _get_store(repo_root)
try:
data = build_report_data(review_data)
data["summary"] = review_data.get("summary", "")
if output_path:
base = Path(output_path)
if not base.is_absolute():
base = root / base
else:
base = root / "code-review-report"
written: list[dict[str, Any]] = []
if format in ("html", "both"):
template = _load_report_template()
rendered = template.replace(
"{{REPORT_DATA}}", json.dumps(data, ensure_ascii=False)
)
html_path = base.with_suffix(".html")
html_path.parent.mkdir(parents=True, exist_ok=True)
html_path.write_text(rendered, encoding="utf-8")
written.append({
"format": "html",
"output_path": str(html_path),
"size_bytes": len(rendered),
})
if format in ("markdown", "both"):
rendered_md = render_markdown_report(review_data)
md_path = base.with_suffix(".md")
md_path.parent.mkdir(parents=True, exist_ok=True)
md_path.write_text(rendered_md, encoding="utf-8")
written.append({
"format": "markdown",
"output_path": str(md_path),
"size_bytes": len(rendered_md),
})
paths = [w["output_path"] for w in written]
return {
"status": "ok",
"summary": f"Report written to {', '.join(paths)}",
"output_path": paths[0] if len(paths) == 1 else paths,
"files": written,
"report_size_bytes": sum(w["size_bytes"] for w in written),
}
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()