review_report_generator.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. #!/usr/bin/env python3
  2. """
  3. Review Report Generator
  4. Generates comprehensive code review reports by combining PR analysis
  5. and code quality findings into structured, actionable reports.
  6. Usage:
  7. python review_report_generator.py /path/to/repo
  8. python review_report_generator.py . --pr-analysis pr_results.json --quality-analysis quality_results.json
  9. python review_report_generator.py /path/to/repo --format markdown --output review.md
  10. """
  11. import argparse
  12. import json
  13. import os
  14. import subprocess
  15. import sys
  16. from datetime import datetime
  17. from pathlib import Path
  18. from typing import Dict, List, Optional, Tuple
  19. # Severity weights for prioritization
  20. SEVERITY_WEIGHTS = {
  21. "critical": 100,
  22. "high": 75,
  23. "medium": 50,
  24. "low": 25,
  25. "info": 10
  26. }
  27. # Review verdict thresholds
  28. VERDICT_THRESHOLDS = {
  29. "approve": {"max_critical": 0, "max_high": 0, "max_score": 100},
  30. "approve_with_suggestions": {"max_critical": 0, "max_high": 2, "max_score": 85},
  31. "request_changes": {"max_critical": 0, "max_high": 5, "max_score": 70},
  32. "block": {"max_critical": float("inf"), "max_high": float("inf"), "max_score": 0}
  33. }
  34. def load_json_file(filepath: str) -> Optional[Dict]:
  35. """Load JSON file if it exists."""
  36. try:
  37. with open(filepath, "r") as f:
  38. return json.load(f)
  39. except (FileNotFoundError, json.JSONDecodeError):
  40. return None
  41. def run_pr_analyzer(repo_path: Path) -> Dict:
  42. """Run pr_analyzer.py and return results."""
  43. script_path = Path(__file__).parent / "pr_analyzer.py"
  44. if not script_path.exists():
  45. return {"status": "error", "message": "pr_analyzer.py not found"}
  46. try:
  47. result = subprocess.run(
  48. [sys.executable, str(script_path), str(repo_path), "--json"],
  49. capture_output=True,
  50. text=True,
  51. timeout=120
  52. )
  53. if result.returncode == 0:
  54. return json.loads(result.stdout)
  55. return {"status": "error", "message": result.stderr}
  56. except Exception as e:
  57. return {"status": "error", "message": str(e)}
  58. def run_quality_checker(repo_path: Path) -> Dict:
  59. """Run code_quality_checker.py and return results."""
  60. script_path = Path(__file__).parent / "code_quality_checker.py"
  61. if not script_path.exists():
  62. return {"status": "error", "message": "code_quality_checker.py not found"}
  63. try:
  64. result = subprocess.run(
  65. [sys.executable, str(script_path), str(repo_path), "--json"],
  66. capture_output=True,
  67. text=True,
  68. timeout=300
  69. )
  70. if result.returncode == 0:
  71. return json.loads(result.stdout)
  72. return {"status": "error", "message": result.stderr}
  73. except Exception as e:
  74. return {"status": "error", "message": str(e)}
  75. def calculate_review_score(pr_analysis: Dict, quality_analysis: Dict) -> int:
  76. """Calculate overall review score (0-100)."""
  77. score = 100
  78. # Deduct for PR risks
  79. if "risks" in pr_analysis:
  80. risks = pr_analysis["risks"]
  81. score -= len(risks.get("critical", [])) * 15
  82. score -= len(risks.get("high", [])) * 10
  83. score -= len(risks.get("medium", [])) * 5
  84. score -= len(risks.get("low", [])) * 2
  85. # Deduct for code quality issues
  86. if "issues" in quality_analysis:
  87. issues = quality_analysis["issues"]
  88. score -= len([i for i in issues if i.get("severity") == "critical"]) * 12
  89. score -= len([i for i in issues if i.get("severity") == "high"]) * 8
  90. score -= len([i for i in issues if i.get("severity") == "medium"]) * 4
  91. score -= len([i for i in issues if i.get("severity") == "low"]) * 1
  92. # Deduct for complexity
  93. if "summary" in pr_analysis:
  94. complexity = pr_analysis["summary"].get("complexity_score", 0)
  95. if complexity > 7:
  96. score -= 10
  97. elif complexity > 5:
  98. score -= 5
  99. return max(0, min(100, score))
  100. def determine_verdict(score: int, critical_count: int, high_count: int) -> Tuple[str, str]:
  101. """Determine review verdict based on score and issue counts."""
  102. if critical_count > 0:
  103. return "block", "Critical issues must be resolved before merge"
  104. if score >= 90 and high_count == 0:
  105. return "approve", "Code meets quality standards"
  106. if score >= 75 and high_count <= 2:
  107. return "approve_with_suggestions", "Minor improvements recommended"
  108. if score >= 50:
  109. return "request_changes", "Several issues need to be addressed"
  110. return "block", "Significant issues prevent approval"
  111. def generate_findings_list(pr_analysis: Dict, quality_analysis: Dict) -> List[Dict]:
  112. """Combine and prioritize all findings."""
  113. findings = []
  114. # Add PR risk findings
  115. if "risks" in pr_analysis:
  116. for severity, items in pr_analysis["risks"].items():
  117. for item in items:
  118. findings.append({
  119. "source": "pr_analysis",
  120. "severity": severity,
  121. "category": item.get("name", "unknown"),
  122. "message": item.get("message", ""),
  123. "file": item.get("file", ""),
  124. "count": item.get("count", 1)
  125. })
  126. # Add code quality findings
  127. if "issues" in quality_analysis:
  128. for issue in quality_analysis["issues"]:
  129. findings.append({
  130. "source": "quality_analysis",
  131. "severity": issue.get("severity", "medium"),
  132. "category": issue.get("type", "unknown"),
  133. "message": issue.get("message", ""),
  134. "file": issue.get("file", ""),
  135. "line": issue.get("line", 0)
  136. })
  137. # Sort by severity weight
  138. findings.sort(
  139. key=lambda x: -SEVERITY_WEIGHTS.get(x["severity"], 0)
  140. )
  141. return findings
  142. def generate_action_items(findings: List[Dict]) -> List[Dict]:
  143. """Generate prioritized action items from findings."""
  144. action_items = []
  145. seen_categories = set()
  146. for finding in findings:
  147. category = finding["category"]
  148. severity = finding["severity"]
  149. # Group similar issues
  150. if category in seen_categories and severity not in ["critical", "high"]:
  151. continue
  152. action = {
  153. "priority": "P0" if severity == "critical" else "P1" if severity == "high" else "P2",
  154. "action": get_action_for_category(category, finding),
  155. "severity": severity,
  156. "files_affected": [finding["file"]] if finding.get("file") else []
  157. }
  158. action_items.append(action)
  159. seen_categories.add(category)
  160. return action_items[:15] # Top 15 actions
  161. def get_action_for_category(category: str, finding: Dict) -> str:
  162. """Get actionable recommendation for issue category."""
  163. actions = {
  164. "hardcoded_secrets": "Remove hardcoded credentials and use environment variables or a secrets manager",
  165. "sql_concatenation": "Use parameterized queries to prevent SQL injection",
  166. "debugger": "Remove debugger statements before merging",
  167. "console_log": "Remove or replace console statements with proper logging",
  168. "todo_fixme": "Address TODO/FIXME comments or create tracking issues",
  169. "disable_eslint": "Address the underlying issue instead of disabling lint rules",
  170. "any_type": "Replace 'any' types with proper type definitions",
  171. "long_function": "Break down function into smaller, focused units",
  172. "god_class": "Split class into smaller, single-responsibility classes",
  173. "too_many_params": "Use parameter objects or builder pattern",
  174. "deep_nesting": "Refactor using early returns, guard clauses, or extraction",
  175. "high_complexity": "Reduce cyclomatic complexity through refactoring",
  176. "missing_error_handling": "Add proper error handling and recovery logic",
  177. "duplicate_code": "Extract duplicate code into shared functions",
  178. "magic_numbers": "Replace magic numbers with named constants",
  179. "large_file": "Consider splitting into multiple smaller modules"
  180. }
  181. return actions.get(category, f"Review and address: {finding.get('message', category)}")
  182. def format_markdown_report(report: Dict) -> str:
  183. """Generate markdown-formatted report."""
  184. lines = []
  185. # Header
  186. lines.append("# Code Review Report")
  187. lines.append("")
  188. lines.append(f"**Generated:** {report['metadata']['generated_at']}")
  189. lines.append(f"**Repository:** {report['metadata']['repository']}")
  190. lines.append("")
  191. # Executive Summary
  192. lines.append("## Executive Summary")
  193. lines.append("")
  194. summary = report["summary"]
  195. verdict = summary["verdict"]
  196. verdict_emoji = {
  197. "approve": "✅",
  198. "approve_with_suggestions": "✅",
  199. "request_changes": "⚠️",
  200. "block": "❌"
  201. }.get(verdict, "❓")
  202. lines.append(f"**Verdict:** {verdict_emoji} {verdict.upper().replace('_', ' ')}")
  203. lines.append(f"**Score:** {summary['score']}/100")
  204. lines.append(f"**Rationale:** {summary['rationale']}")
  205. lines.append("")
  206. # Issue Counts
  207. lines.append("### Issue Summary")
  208. lines.append("")
  209. lines.append("| Severity | Count |")
  210. lines.append("|----------|-------|")
  211. for severity in ["critical", "high", "medium", "low"]:
  212. count = summary["issue_counts"].get(severity, 0)
  213. lines.append(f"| {severity.capitalize()} | {count} |")
  214. lines.append("")
  215. # PR Statistics (if available)
  216. if "pr_summary" in report:
  217. pr = report["pr_summary"]
  218. lines.append("### Change Statistics")
  219. lines.append("")
  220. lines.append(f"- **Files Changed:** {pr.get('files_changed', 'N/A')}")
  221. lines.append(f"- **Lines Added:** +{pr.get('total_additions', 0)}")
  222. lines.append(f"- **Lines Removed:** -{pr.get('total_deletions', 0)}")
  223. lines.append(f"- **Complexity:** {pr.get('complexity_label', 'N/A')}")
  224. lines.append("")
  225. # Action Items
  226. if report.get("action_items"):
  227. lines.append("## Action Items")
  228. lines.append("")
  229. for i, item in enumerate(report["action_items"], 1):
  230. priority = item["priority"]
  231. emoji = "🔴" if priority == "P0" else "🟠" if priority == "P1" else "🟡"
  232. lines.append(f"{i}. {emoji} **[{priority}]** {item['action']}")
  233. if item.get("files_affected"):
  234. lines.append(f" - Files: {', '.join(item['files_affected'][:3])}")
  235. lines.append("")
  236. # Critical Findings
  237. critical_findings = [f for f in report.get("findings", []) if f["severity"] == "critical"]
  238. if critical_findings:
  239. lines.append("## Critical Issues (Must Fix)")
  240. lines.append("")
  241. for finding in critical_findings:
  242. lines.append(f"- **{finding['category']}** in `{finding.get('file', 'unknown')}`")
  243. lines.append(f" - {finding['message']}")
  244. lines.append("")
  245. # High Priority Findings
  246. high_findings = [f for f in report.get("findings", []) if f["severity"] == "high"]
  247. if high_findings:
  248. lines.append("## High Priority Issues")
  249. lines.append("")
  250. for finding in high_findings[:10]:
  251. lines.append(f"- **{finding['category']}** in `{finding.get('file', 'unknown')}`")
  252. lines.append(f" - {finding['message']}")
  253. lines.append("")
  254. # Review Order (if available)
  255. if "review_order" in report:
  256. lines.append("## Suggested Review Order")
  257. lines.append("")
  258. for i, filepath in enumerate(report["review_order"][:10], 1):
  259. lines.append(f"{i}. `{filepath}`")
  260. lines.append("")
  261. # Footer
  262. lines.append("---")
  263. lines.append("*Generated by Code Reviewer*")
  264. return "\n".join(lines)
  265. def format_text_report(report: Dict) -> str:
  266. """Generate plain text report."""
  267. lines = []
  268. lines.append("=" * 60)
  269. lines.append("CODE REVIEW REPORT")
  270. lines.append("=" * 60)
  271. lines.append("")
  272. lines.append(f"Generated: {report['metadata']['generated_at']}")
  273. lines.append(f"Repository: {report['metadata']['repository']}")
  274. lines.append("")
  275. summary = report["summary"]
  276. verdict = summary["verdict"].upper().replace("_", " ")
  277. lines.append(f"VERDICT: {verdict}")
  278. lines.append(f"SCORE: {summary['score']}/100")
  279. lines.append(f"RATIONALE: {summary['rationale']}")
  280. lines.append("")
  281. lines.append("--- ISSUE SUMMARY ---")
  282. for severity in ["critical", "high", "medium", "low"]:
  283. count = summary["issue_counts"].get(severity, 0)
  284. lines.append(f" {severity.capitalize()}: {count}")
  285. lines.append("")
  286. if report.get("action_items"):
  287. lines.append("--- ACTION ITEMS ---")
  288. for i, item in enumerate(report["action_items"][:10], 1):
  289. lines.append(f" {i}. [{item['priority']}] {item['action']}")
  290. lines.append("")
  291. critical = [f for f in report.get("findings", []) if f["severity"] == "critical"]
  292. if critical:
  293. lines.append("--- CRITICAL ISSUES ---")
  294. for f in critical:
  295. lines.append(f" [{f.get('file', 'unknown')}] {f['message']}")
  296. lines.append("")
  297. lines.append("=" * 60)
  298. return "\n".join(lines)
  299. def generate_report(
  300. repo_path: Path,
  301. pr_analysis: Optional[Dict] = None,
  302. quality_analysis: Optional[Dict] = None
  303. ) -> Dict:
  304. """Generate comprehensive review report."""
  305. # Run analyses if not provided
  306. if pr_analysis is None:
  307. pr_analysis = run_pr_analyzer(repo_path)
  308. if quality_analysis is None:
  309. quality_analysis = run_quality_checker(repo_path)
  310. # Generate findings
  311. findings = generate_findings_list(pr_analysis, quality_analysis)
  312. # Count issues by severity
  313. issue_counts = {
  314. "critical": len([f for f in findings if f["severity"] == "critical"]),
  315. "high": len([f for f in findings if f["severity"] == "high"]),
  316. "medium": len([f for f in findings if f["severity"] == "medium"]),
  317. "low": len([f for f in findings if f["severity"] == "low"])
  318. }
  319. # Calculate score and verdict
  320. score = calculate_review_score(pr_analysis, quality_analysis)
  321. verdict, rationale = determine_verdict(
  322. score,
  323. issue_counts["critical"],
  324. issue_counts["high"]
  325. )
  326. # Generate action items
  327. action_items = generate_action_items(findings)
  328. # Build report
  329. report = {
  330. "metadata": {
  331. "generated_at": datetime.now().isoformat(),
  332. "repository": str(repo_path),
  333. "version": "1.0.0"
  334. },
  335. "summary": {
  336. "score": score,
  337. "verdict": verdict,
  338. "rationale": rationale,
  339. "issue_counts": issue_counts
  340. },
  341. "findings": findings,
  342. "action_items": action_items
  343. }
  344. # Add PR summary if available
  345. if pr_analysis.get("status") == "analyzed":
  346. report["pr_summary"] = pr_analysis.get("summary", {})
  347. report["review_order"] = pr_analysis.get("review_order", [])
  348. # Add quality summary if available
  349. if quality_analysis.get("status") == "analyzed":
  350. report["quality_summary"] = quality_analysis.get("summary", {})
  351. return report
  352. def main():
  353. parser = argparse.ArgumentParser(
  354. description="Generate comprehensive code review reports"
  355. )
  356. parser.add_argument(
  357. "repo_path",
  358. nargs="?",
  359. default=".",
  360. help="Path to repository (default: current directory)"
  361. )
  362. parser.add_argument(
  363. "--pr-analysis",
  364. help="Path to pre-computed PR analysis JSON"
  365. )
  366. parser.add_argument(
  367. "--quality-analysis",
  368. help="Path to pre-computed quality analysis JSON"
  369. )
  370. parser.add_argument(
  371. "--format", "-f",
  372. choices=["text", "markdown", "json"],
  373. default="text",
  374. help="Output format (default: text)"
  375. )
  376. parser.add_argument(
  377. "--output", "-o",
  378. help="Write output to file"
  379. )
  380. parser.add_argument(
  381. "--json",
  382. action="store_true",
  383. help="Output as JSON (shortcut for --format json)"
  384. )
  385. args = parser.parse_args()
  386. repo_path = Path(args.repo_path).resolve()
  387. if not repo_path.exists():
  388. print(f"Error: Path does not exist: {repo_path}", file=sys.stderr)
  389. sys.exit(1)
  390. # Load pre-computed analyses if provided
  391. pr_analysis = None
  392. quality_analysis = None
  393. if args.pr_analysis:
  394. pr_analysis = load_json_file(args.pr_analysis)
  395. if not pr_analysis:
  396. print(f"Warning: Could not load PR analysis from {args.pr_analysis}")
  397. if args.quality_analysis:
  398. quality_analysis = load_json_file(args.quality_analysis)
  399. if not quality_analysis:
  400. print(f"Warning: Could not load quality analysis from {args.quality_analysis}")
  401. # Generate report
  402. report = generate_report(repo_path, pr_analysis, quality_analysis)
  403. # Format output
  404. output_format = "json" if args.json else args.format
  405. if output_format == "json":
  406. output = json.dumps(report, indent=2)
  407. elif output_format == "markdown":
  408. output = format_markdown_report(report)
  409. else:
  410. output = format_text_report(report)
  411. # Write or print output
  412. if args.output:
  413. with open(args.output, "w") as f:
  414. f.write(output)
  415. print(f"Report written to {args.output}")
  416. else:
  417. print(output)
  418. if __name__ == "__main__":
  419. main()