"""Markdown renderer for PR analysis results.
Pure functions over `PRAnalysisResult` โ no I/O, no logging side effects, no
network. The GitHub Action uses these to build the sticky comment and the
Check Run summary; the same renderer is reusable later for HTML/PDF reports
since the inputs are model objects and the outputs are plain strings.
"""
from __future__ import annotations
from typing import Optional
from models.pr_intelligence import PRAnalysisResult
# Hidden HTML marker that tags the sticky comment so subsequent runs can
# locate and update it instead of creating a duplicate.
STICKY_MARKER = ""
# Hard cap on rows we inline in the changed-files table so the comment stays
# under GitHub's 65 KB body limit on huge PRs.
# ponytail: fixed cap; if PRs routinely overflow, switch to a separate gist link.
_MAX_FILE_ROWS = 50
_RISK_BADGES = {
"LOW": "๐ข LOW",
"MEDIUM": "๐ก MEDIUM",
"HIGH": "๐ HIGH",
"CRITICAL": "๐ด CRITICAL",
}
_BLAST_BADGES = {
"LOW": "๐ข LOW",
"MEDIUM": "๐ก MEDIUM",
"HIGH": "๐ HIGH",
"EXTREME": "๐ด EXTREME",
}
def render_pr_review(result: PRAnalysisResult) -> str:
"""Returns the full sticky-comment Markdown body, marker on the first line."""
lines: list[str] = [
STICKY_MARKER,
"# Repository Intelligence Review",
"",
f"**PR:** [#{result.pr_number} {result.pr_title}]({result.pr_url}) ยท "
f"`{result.pr_state}` ยท size **{result.pr_size}**",
"",
f"## Overall Risk: {_RISK_BADGES.get(result.risk_level, result.risk_level)} "
f"({result.risk_score}/100)",
"",
f"- Blast radius: {_BLAST_BADGES.get(result.blast_radius, result.blast_radius)} "
f"(impact radius {result.impact_radius}, max depth {result.max_depth})",
f"- Changes: **+{result.total_additions} / โ{result.total_deletions}** "
f"across {len(result.changed_files)} files",
f"- Symbols: +{len(result.added_symbols)} added ยท "
f"~{len(result.modified_symbols)} modified ยท "
f"โ{len(result.removed_symbols)} removed",
"",
]
if result.top_risks:
lines.append("### Top Risk Factors")
for risk in result.top_risks[:5]:
lines.append(f"- {risk}")
lines.append("")
if result.risk_breakdown:
lines.append("Risk Breakdown
")
lines.append("")
lines.append("| Factor | Score | Detail |")
lines.append("|---|---:|---|")
for rb in result.risk_breakdown:
detail = rb.detail.replace("|", "\\|").replace("\n", " ")
lines.append(f"| {rb.factor} | {rb.score} | {detail} |")
lines.append(" ")
lines.append("")
if result.review_focus_areas:
lines.append("### Review Focus Areas")
for area in result.review_focus_areas:
lines.append(f"- **[{area.priority}] {area.area}** โ {area.reason}")
if area.files:
files_preview = ", ".join(f"`{f}`" for f in area.files[:3])
extra = f" (+{len(area.files) - 3} more)" if len(area.files) > 3 else ""
lines.append(f" - Files: {files_preview}{extra}")
lines.append("")
critical = (
result.changed_entry_points
or result.changed_core_files
or result.changed_high_coupling_files
)
if critical:
lines.append("Critical Files Touched
")
lines.append("")
if result.changed_entry_points:
lines.append(
"**Entry points:** "
+ ", ".join(f"`{f}`" for f in result.changed_entry_points)
)
lines.append("")
if result.changed_core_files:
lines.append(
"**Core files:** "
+ ", ".join(f"`{f}`" for f in result.changed_core_files)
)
lines.append("")
if result.changed_high_coupling_files:
lines.append(
"**High-coupling files:** "
+ ", ".join(f"`{f}`" for f in result.changed_high_coupling_files)
)
lines.append("")
lines.append(" ")
lines.append("")
if result.changed_files:
lines.append(
f"Changed Files ({len(result.changed_files)} total)"
"
"
)
lines.append("")
lines.append("| File | Status | +/โ |")
lines.append("|---|---|---:|")
for cf in result.changed_files[:_MAX_FILE_ROWS]:
lines.append(
f"| `{cf.filename}` | {cf.status} | +{cf.additions} / โ{cf.deletions} |"
)
if len(result.changed_files) > _MAX_FILE_ROWS:
remaining = len(result.changed_files) - _MAX_FILE_ROWS
lines.append(f"| _โฆ {remaining} more_ | | |")
lines.append(" ")
lines.append("")
lines.append(f"Analyzed at {result.analyzed_at} ยท repo `{result.repo}`")
return "\n".join(lines)
def render_check_summary(
result: PRAnalysisResult,
elapsed_seconds: float,
comment_url: Optional[str] = None,
) -> str:
"""Short Markdown for the Check Run `output.summary` field."""
parts = [
f"**Risk:** {_RISK_BADGES.get(result.risk_level, result.risk_level)} "
f"({result.risk_score}/100, blast {result.blast_radius})",
f"**Changes:** +{result.total_additions} / โ{result.total_deletions} "
f"across {len(result.changed_files)} files",
f"**Symbols:** +{len(result.added_symbols)} / "
f"~{len(result.modified_symbols)} / โ{len(result.removed_symbols)}",
f"**Elapsed:** {elapsed_seconds:.1f}s",
]
if comment_url:
parts.append(f"[Full review comment โ]({comment_url})")
return "\n\n".join(parts)
# Maps risk level โ GitHub Check Run conclusion.
# Defaults to advisory ("neutral") so the Action doesn't block merges on
# heuristic risk scores. Teams can wrap this in their own gating workflow.
_CONCLUSION_BY_RISK = {
"LOW": "success",
"MEDIUM": "neutral",
"HIGH": "neutral",
"CRITICAL": "neutral",
}
def check_conclusion_for(risk_level: str) -> str:
"""Returns a GitHub Check Run `conclusion` string for the given risk level."""
return _CONCLUSION_BY_RISK.get(risk_level, "neutral")