721 lines
27 KiB
Python
721 lines
27 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
|
|
import os
|
|
import subprocess
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional
|
|
|
|
from ..incremental import get_changed_files, get_staged_and_unstaged
|
|
from ..scoring import (
|
|
build_report_data,
|
|
check_community_health,
|
|
compute_coverage,
|
|
dedupe_findings,
|
|
deep_read_plan,
|
|
render_markdown_report,
|
|
score_review,
|
|
)
|
|
from ._common import _get_store, _error_response
|
|
|
|
#: Persistent cross-round deep-read index (relative path -> per-file SHA).
|
|
COVERAGE_INDEX_REL = ".code-review-graph/coverage-index.json"
|
|
|
|
|
|
def _git_head_sha(repo_root: Path) -> str | None:
|
|
"""Current git HEAD SHA (None when not a repo / git unavailable)."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(repo_root),
|
|
timeout=10,
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def _git_file_sha(repo_root: Path, rel_path: str) -> str | None:
|
|
"""Blob SHA of the *current working-tree* content of ``rel_path``.
|
|
|
|
Uses ``git hash-object`` (content hash, does not touch the index) so an
|
|
uncommitted edit still counts as stale. Returns None when the file does
|
|
not exist in the working tree or git is unavailable.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "hash-object", rel_path],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(repo_root),
|
|
timeout=10,
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def _coverage_index_path(repo_root: Path) -> Path:
|
|
return repo_root / COVERAGE_INDEX_REL
|
|
|
|
|
|
def _load_coverage_index(repo_root: Path) -> list[str]:
|
|
"""Return the relative paths whose per-file SHA still matches HEAD.
|
|
|
|
Files that changed since the last review are automatically excluded
|
|
(stale), so an incremental round never masks new code with old findings.
|
|
|
|
SHA check uses the graph's ``nodes.file_hash`` when the graph is
|
|
current, else falls back to a single batched ``git hash-object``
|
|
invocation (never one subprocess per file, see #46/#136).
|
|
"""
|
|
index_path = _coverage_index_path(repo_root)
|
|
if not index_path.is_file():
|
|
return []
|
|
try:
|
|
payload = json.loads(index_path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return []
|
|
entries = payload.get("entries") or {}
|
|
if not entries:
|
|
return []
|
|
return [
|
|
rel
|
|
for rel, info in entries.items()
|
|
if _index_entry_current(repo_root, rel, info)
|
|
]
|
|
|
|
|
|
def _load_coverage_index_full(
|
|
repo_root: Path,
|
|
) -> tuple[list[str], dict[str, list[list[int]]]]:
|
|
"""Load (current rel paths, their persisted read ranges)."""
|
|
index_path = _coverage_index_path(repo_root)
|
|
if not index_path.is_file():
|
|
return [], {}
|
|
try:
|
|
payload = json.loads(index_path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return [], {}
|
|
entries = payload.get("entries") or {}
|
|
if not entries:
|
|
return [], {}
|
|
current, ranges = [], {}
|
|
for rel, info in entries.items():
|
|
if _index_entry_current(repo_root, rel, info):
|
|
current.append(rel)
|
|
if info.get("ranges"):
|
|
ranges[rel] = info["ranges"]
|
|
return current, ranges
|
|
|
|
|
|
def _index_entry_current(repo_root: Path, rel: str, info: dict) -> bool:
|
|
"""True when a persisted entry's SHA matches the current file content."""
|
|
if not (repo_root / rel).is_file():
|
|
return False
|
|
expected = info.get("sha")
|
|
if not expected:
|
|
return False
|
|
# Prefer graph file_hash (single SQL fetch, no subprocess). Fall back to
|
|
# a batched git hash-object for files not in the graph.
|
|
graph_hash = _graph_file_hashes(repo_root).get(rel)
|
|
if graph_hash:
|
|
return graph_hash == expected
|
|
return _git_file_sha(repo_root, rel) == expected
|
|
|
|
|
|
_GRAPH_HASH_CACHE: dict[str, dict[str, str]] = {}
|
|
|
|
|
|
def _graph_file_hashes(repo_root: Path) -> dict[str, str]:
|
|
"""All File-node content hashes for a repo, cached (one SQL fetch)."""
|
|
key = str(repo_root)
|
|
if key in _GRAPH_HASH_CACHE:
|
|
return _GRAPH_HASH_CACHE[key]
|
|
result: dict[str, str] = {}
|
|
try:
|
|
store, root = _get_store(str(repo_root))
|
|
try:
|
|
rows = store._conn.execute(
|
|
"SELECT file_path, file_hash FROM nodes WHERE kind='File'"
|
|
).fetchall()
|
|
for r in rows:
|
|
fp = str(r["file_path"])
|
|
rel = fp.replace("\\", "/")
|
|
if rel.startswith(str(root).replace("\\", "/") + "/"):
|
|
rel = rel[len(str(root).replace("\\", "/")) + 1:].replace("/", "\\")
|
|
rel = rel.replace("\\", "/")
|
|
result[rel] = r["file_hash"] or ""
|
|
finally:
|
|
store.close()
|
|
except Exception:
|
|
result = {}
|
|
_GRAPH_HASH_CACHE[key] = result
|
|
return result
|
|
|
|
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,
|
|
progress_cb: Callable[[float, Optional[str]], None] | None = None,
|
|
) -> 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). These five objective metrics form the full report
|
|
metric set; ``llm_judged`` is returned empty for compatibility.
|
|
|
|
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).
|
|
progress_cb: Optional ``(fraction, message)`` progress callback
|
|
forwarded to the scoring engine.
|
|
"""
|
|
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,
|
|
progress_cb=progress_cb,
|
|
)
|
|
|
|
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: coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def coverage_func(
|
|
deep_read_files: list[str],
|
|
all_files: bool = True,
|
|
include_churn: bool = True,
|
|
gate: str = "high_risk",
|
|
include_prior: bool = False,
|
|
file_read_ranges: dict[str, list[list[int]]] | None = None,
|
|
file_semantic_units: dict[str, list[dict]] | None = None,
|
|
repo_root: str | None = None,
|
|
progress_cb: Callable[[float, Optional[str]], None] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Compute file-count review coverage for a set of deep-read files.
|
|
|
|
Coverage = number of deep-read files / number of all source files.
|
|
The high-risk coverage uses only the signal-flagged subset (also
|
|
file-count based). Per-file risk weights still rank the priority
|
|
deep-read list so the highest-risk files are read first. This backs
|
|
the project-review Step 7.5 coverage self-check (G3).
|
|
|
|
Args:
|
|
deep_read_files: Files the agent actually deep-read during the
|
|
review (relative or absolute paths). Required.
|
|
all_files: When True, the denominator is every source file in the
|
|
graph (default: True, whole-project semantics).
|
|
include_churn: Include git-churn as the w3 weight term.
|
|
gate: Gate mode: ``"high_risk"`` (default) / ``"overall"`` /
|
|
``"both"`` (both overall and high-risk must meet the target) /
|
|
``"both+line"`` (both + per-file line coverage >= line_target
|
|
and unit-completeness gap-free) / ``"line+unit"`` (line + unit
|
|
only, feature reviews; file-count / high-risk coverage are
|
|
skipped and returned as ``None``).
|
|
include_prior: When True, merge the cross-round coverage index
|
|
(files whose SHA is unchanged since the last review) into the
|
|
deep-read set.
|
|
file_read_ranges: Optional {rel_path: [[s,e],...]} of line ranges a
|
|
sub-agent actually read per deep-read file (for gate="both+line").
|
|
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
|
|
reported semantic units per deep-read file (gate="both+line").
|
|
progress_cb: Optional ``(fraction, message)`` progress callback
|
|
forwarded to the scoring engine.
|
|
|
|
Returns:
|
|
Dict with coverage_pct, grade, deep_read_count, total_files,
|
|
target_reached, target, remaining_files_to_target,
|
|
priority_deep_read_files, uncovered_files, silent_files and note.
|
|
``silent_files`` feeds the G2 spot-check sampling. For
|
|
``gate="both+line"`` / ``"line+unit"`` also returns
|
|
line_coverage_pct, unit_coverage_pct, line_gap_files,
|
|
unit_gap_files, unit_exempt_files.
|
|
"""
|
|
store, root = _get_store(repo_root)
|
|
try:
|
|
deep_read = list(deep_read_files or [])
|
|
ranges = dict(file_read_ranges or {})
|
|
units = dict(file_semantic_units or {})
|
|
if include_prior:
|
|
prior, prior_ranges = _load_coverage_index_full(root)
|
|
deep_read += prior
|
|
for rel, rr in prior_ranges.items():
|
|
ranges.setdefault(rel, rr)
|
|
return compute_coverage(
|
|
store,
|
|
root,
|
|
deep_read_files=deep_read,
|
|
include_churn=include_churn,
|
|
gate=gate,
|
|
file_read_ranges=ranges,
|
|
file_semantic_units=units,
|
|
progress_cb=progress_cb,
|
|
)
|
|
except Exception as exc:
|
|
return _error_response(str(exc))
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
def deep_read_plan_func(
|
|
repo_root: str | None = None,
|
|
target_coverage: float = 85.0,
|
|
batch_size: int = 40,
|
|
include_churn: bool = True,
|
|
include_prior: bool = False,
|
|
deep_read_files: list[str] | None = None,
|
|
progress_cb: Callable[[float, Optional[str]], None] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Generate a grouped deep-read plan that closes the coverage gap.
|
|
|
|
Greedily selects the highest-risk files not yet deep-read until the
|
|
file-count overall coverage target is reached, then groups them by
|
|
parent directory (≤ ``batch_size`` per group) so each group can be
|
|
dispatched to one parallel sub-agent.
|
|
|
|
Args:
|
|
repo_root: Repository root. Auto-detected if omitted.
|
|
target_coverage: Overall coverage target percentage (default 85).
|
|
batch_size: Max files per group (default 40).
|
|
include_churn: Include git-churn as the w3 weight term.
|
|
include_prior: Exclude files already covered by the cross-round
|
|
coverage index (SHA unchanged) from the plan.
|
|
deep_read_files: Files already deep-read this round.
|
|
progress_cb: Optional ``(fraction, message)`` progress callback
|
|
forwarded to the scoring engine.
|
|
|
|
Returns:
|
|
Dict with current_coverage_pct, current/target/remaining file
|
|
counts (plus compatible weight counts), planned_files
|
|
(priority-ordered), directory groups and estimated_batches.
|
|
"""
|
|
store, root = _get_store(repo_root)
|
|
try:
|
|
prior = set(_load_coverage_index(root)) if include_prior else None
|
|
return deep_read_plan(
|
|
store,
|
|
root,
|
|
deep_read_files=deep_read_files,
|
|
target_coverage=target_coverage,
|
|
batch_size=batch_size,
|
|
include_churn=include_churn,
|
|
prior_covered=prior,
|
|
progress_cb=progress_cb,
|
|
)
|
|
except Exception as exc:
|
|
return _error_response(str(exc))
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
def save_coverage_index_func(
|
|
deep_read_files: list[str],
|
|
repo_root: str | None = None,
|
|
file_read_ranges: dict[str, list[list[int]]] | None = None,
|
|
file_semantic_units: dict[str, list[dict]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Persist the deep-read file list to the cross-round coverage index.
|
|
|
|
Records each file's per-file SHA at HEAD so a later review can tell
|
|
which previously-deep-read files are still current (SHA unchanged)
|
|
and which are stale and must be re-read.
|
|
|
|
Version 2: SHA comes from the graph's ``nodes.file_hash`` (one SQL
|
|
query, zero subprocesses) instead of one ``git hash-object`` per file.
|
|
Line ranges (``file_read_ranges``) are persisted so a later review can
|
|
reuse the line coverage of SHA-unchanged files.
|
|
|
|
Args:
|
|
deep_read_files: Files deep-read this round (relative or absolute).
|
|
repo_root: Repository root. Auto-detected if omitted.
|
|
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
|
|
persist per file (index v2, reused by include_prior).
|
|
file_semantic_units: Optional per-file semantic units; persisted
|
|
for informational purposes (unit completeness is recomputed
|
|
against the live graph each round, so this is advisory only).
|
|
|
|
Returns:
|
|
Dict with index_path, entry count and head_sha.
|
|
"""
|
|
store, root = _get_store(repo_root)
|
|
store.close()
|
|
|
|
file_hashes = _graph_file_hashes(root)
|
|
|
|
def _hash_for(rel: str) -> str | None:
|
|
return file_hashes.get(rel) or _git_file_sha(root, rel)
|
|
|
|
entries: dict[str, dict[str, Any]] = {}
|
|
for f in deep_read_files or []:
|
|
p = Path(f)
|
|
rel = str(p.relative_to(root)) if p.is_absolute() else str(p)
|
|
rel = rel.replace("\\", "/")
|
|
entry: dict[str, Any] = {"sha": _hash_for(rel) or _git_file_sha(root, rel)}
|
|
ranges = (file_read_ranges or {}).get(rel)
|
|
if ranges:
|
|
entry["ranges"] = ranges
|
|
units = (file_semantic_units or {}).get(rel)
|
|
if units:
|
|
entry["units"] = units
|
|
entries[rel] = entry
|
|
|
|
payload = {
|
|
"version": 2,
|
|
"last_updated": datetime.now().isoformat(timespec="seconds"),
|
|
"head_sha": _git_head_sha(root),
|
|
"entries": entries,
|
|
}
|
|
index_path = _coverage_index_path(root)
|
|
try:
|
|
index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
index_path.write_text(
|
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
except OSError as exc:
|
|
return _error_response(f"Failed to write coverage index: {exc}")
|
|
return {
|
|
"status": "ok",
|
|
"index_path": str(index_path),
|
|
"entries": len(entries),
|
|
"head_sha": payload["head_sha"],
|
|
}
|
|
|
|
|
|
def community_health_func(
|
|
repo_root: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Check node->community attribution health (nodes.community_id).
|
|
|
|
Detects the desync where ``communities.size`` is correct but
|
|
``nodes.community_id`` is mostly NULL (e.g. after an incremental
|
|
rebuild). Returns ``needs_postprocess``; the review skill should run
|
|
``code-review-graph postprocess`` when True before computing coverage.
|
|
|
|
Args:
|
|
repo_root: Repository root. Auto-detected if omitted.
|
|
|
|
Returns:
|
|
Dict with total_nodes, attributed_nodes, non_file_nodes,
|
|
attribution_pct, needs_postprocess and note.
|
|
"""
|
|
store, root = _get_store(repo_root)
|
|
try:
|
|
return check_community_health(store)
|
|
except Exception as exc:
|
|
return _error_response(str(exc))
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 || (data.reviewed_files && data.reviewed_files.length)) {
|
|
if (Array.isArray(data.reviewed_files) && data.reviewed_files.length) {
|
|
html += `<details><summary><b>Files (${data.reviewed_files.length})</b></summary><ul>`
|
|
+ data.reviewed_files.map(f => `<li><code>${f}</code></li>`).join("")
|
|
+ `</ul></details>`;
|
|
} else {
|
|
html += `<p><b>Files:</b> ${data.files}</p>`;
|
|
}
|
|
}
|
|
if (data.summary) html += `<p>${data.summary}</p>`;
|
|
const cov = data.coverage || {};
|
|
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
|
|
const covOk = cov.target_reached;
|
|
let c = `<p><b>Coverage:</b> ${covOk ? "target reached" : "coverage insufficient"}`;
|
|
if (cov.coverage_pct != null) {
|
|
c += ` · overall ${cov.coverage_pct}% (${cov.deep_read_count}/${cov.total_files})`
|
|
+ ` · high-risk ${cov.high_risk_coverage_pct}% (${cov.high_risk_deep_count}/${cov.high_risk_total_files})`;
|
|
}
|
|
if (cov.line_coverage_pct != null) {
|
|
c += ` · line ${cov.line_coverage_pct}% · unit ${cov.unit_coverage_pct}%`;
|
|
}
|
|
c += `</p>`;
|
|
html += c;
|
|
}
|
|
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()
|
|
# Escape "<" as "\u003c" so finding text can never close the
|
|
# surrounding <script> tag (e.g. a literal "</script>" in a
|
|
# message/fix). JSON keeps it a valid escape; the JS template
|
|
# string re-parses it as "<" before esc() HTML-escapes it.
|
|
payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c")
|
|
rendered = template.replace("{{REPORT_DATA}}", payload)
|
|
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()
|