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
313 lines
11 KiB
Python
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>
|
|
· Tier: ${data.tier || "standard"} · 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()
|