pr_analyzer.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. #!/usr/bin/env python3
  2. """
  3. PR Analyzer
  4. Analyzes pull request changes for review complexity, risk assessment,
  5. and generates review priorities.
  6. Usage:
  7. python pr_analyzer.py /path/to/repo
  8. python pr_analyzer.py . --base main --head feature-branch
  9. python pr_analyzer.py /path/to/repo --json
  10. """
  11. import argparse
  12. import json
  13. import os
  14. import re
  15. import subprocess
  16. import sys
  17. from pathlib import Path
  18. from typing import Dict, List, Optional, Tuple
  19. # File categories for review prioritization
  20. FILE_CATEGORIES = {
  21. "critical": {
  22. "patterns": [
  23. r"auth", r"security", r"password", r"token", r"secret",
  24. r"payment", r"billing", r"crypto", r"encrypt"
  25. ],
  26. "weight": 5,
  27. "description": "Security-sensitive files requiring careful review"
  28. },
  29. "high": {
  30. "patterns": [
  31. r"api", r"database", r"migration", r"schema", r"model",
  32. r"config", r"env", r"middleware"
  33. ],
  34. "weight": 4,
  35. "description": "Core infrastructure files"
  36. },
  37. "medium": {
  38. "patterns": [
  39. r"service", r"controller", r"handler", r"util", r"helper"
  40. ],
  41. "weight": 3,
  42. "description": "Business logic files"
  43. },
  44. "low": {
  45. "patterns": [
  46. r"test", r"spec", r"mock", r"fixture", r"story",
  47. r"readme", r"docs", r"\.md$"
  48. ],
  49. "weight": 1,
  50. "description": "Tests and documentation"
  51. }
  52. }
  53. # Risky patterns to flag
  54. RISK_PATTERNS = [
  55. {
  56. "name": "hardcoded_secrets",
  57. "pattern": r"(password|secret|api_key|token)\s*[=:]\s*['\"][^'\"]+['\"]",
  58. "severity": "critical",
  59. "message": "Potential hardcoded secret detected"
  60. },
  61. {
  62. "name": "todo_fixme",
  63. "pattern": r"(TODO|FIXME|HACK|XXX):",
  64. "severity": "low",
  65. "message": "TODO/FIXME comment found"
  66. },
  67. {
  68. "name": "console_log",
  69. "pattern": r"console\.(log|debug|info|warn|error)\(",
  70. "severity": "medium",
  71. "message": "Console statement found (remove for production)"
  72. },
  73. {
  74. "name": "debugger",
  75. "pattern": r"\bdebugger\b",
  76. "severity": "high",
  77. "message": "Debugger statement found"
  78. },
  79. {
  80. "name": "disable_eslint",
  81. "pattern": r"eslint-disable",
  82. "severity": "medium",
  83. "message": "ESLint rule disabled"
  84. },
  85. {
  86. "name": "any_type",
  87. "pattern": r":\s*any\b",
  88. "severity": "medium",
  89. "message": "TypeScript 'any' type used"
  90. },
  91. {
  92. "name": "sql_concatenation",
  93. "pattern": r"(SELECT|INSERT|UPDATE|DELETE).*\+.*['\"]",
  94. "severity": "critical",
  95. "message": "Potential SQL injection (string concatenation in query)"
  96. }
  97. ]
  98. def run_git_command(cmd: List[str], cwd: Path) -> Tuple[bool, str]:
  99. """Run a git command and return success status and output."""
  100. try:
  101. result = subprocess.run(
  102. cmd,
  103. cwd=cwd,
  104. capture_output=True,
  105. text=True,
  106. timeout=30
  107. )
  108. return result.returncode == 0, result.stdout.strip()
  109. except subprocess.TimeoutExpired:
  110. return False, "Command timed out"
  111. except Exception as e:
  112. return False, str(e)
  113. def get_changed_files(repo_path: Path, base: str, head: str) -> List[Dict]:
  114. """Get list of changed files between two refs."""
  115. success, output = run_git_command(
  116. ["git", "diff", "--name-status", f"{base}...{head}"],
  117. repo_path
  118. )
  119. if not success:
  120. # Try without the triple dot (for uncommitted changes)
  121. success, output = run_git_command(
  122. ["git", "diff", "--name-status", base, head],
  123. repo_path
  124. )
  125. if not success or not output:
  126. # Fall back to staged changes
  127. success, output = run_git_command(
  128. ["git", "diff", "--name-status", "--cached"],
  129. repo_path
  130. )
  131. files = []
  132. for line in output.split("\n"):
  133. if not line.strip():
  134. continue
  135. parts = line.split("\t")
  136. if len(parts) >= 2:
  137. status = parts[0][0] # First character of status
  138. filepath = parts[-1] # Handle renames (R100\told\tnew)
  139. status_map = {
  140. "A": "added",
  141. "M": "modified",
  142. "D": "deleted",
  143. "R": "renamed",
  144. "C": "copied"
  145. }
  146. files.append({
  147. "path": filepath,
  148. "status": status_map.get(status, "modified")
  149. })
  150. return files
  151. def get_file_diff(repo_path: Path, filepath: str, base: str, head: str) -> str:
  152. """Get diff content for a specific file."""
  153. success, output = run_git_command(
  154. ["git", "diff", f"{base}...{head}", "--", filepath],
  155. repo_path
  156. )
  157. if not success:
  158. success, output = run_git_command(
  159. ["git", "diff", "--cached", "--", filepath],
  160. repo_path
  161. )
  162. return output if success else ""
  163. def categorize_file(filepath: str) -> Tuple[str, int]:
  164. """Categorize a file based on its path and name."""
  165. filepath_lower = filepath.lower()
  166. for category, info in FILE_CATEGORIES.items():
  167. for pattern in info["patterns"]:
  168. if re.search(pattern, filepath_lower):
  169. return category, info["weight"]
  170. return "medium", 2 # Default category
  171. def analyze_diff_for_risks(diff_content: str, filepath: str) -> List[Dict]:
  172. """Analyze diff content for risky patterns."""
  173. risks = []
  174. # Only analyze added lines (starting with +)
  175. added_lines = [
  176. line[1:] for line in diff_content.split("\n")
  177. if line.startswith("+") and not line.startswith("+++")
  178. ]
  179. content = "\n".join(added_lines)
  180. for risk in RISK_PATTERNS:
  181. matches = re.findall(risk["pattern"], content, re.IGNORECASE)
  182. if matches:
  183. risks.append({
  184. "name": risk["name"],
  185. "severity": risk["severity"],
  186. "message": risk["message"],
  187. "file": filepath,
  188. "count": len(matches)
  189. })
  190. return risks
  191. def count_changes(diff_content: str) -> Dict[str, int]:
  192. """Count additions and deletions in diff."""
  193. additions = 0
  194. deletions = 0
  195. for line in diff_content.split("\n"):
  196. if line.startswith("+") and not line.startswith("+++"):
  197. additions += 1
  198. elif line.startswith("-") and not line.startswith("---"):
  199. deletions += 1
  200. return {"additions": additions, "deletions": deletions}
  201. def calculate_complexity_score(files: List[Dict], all_risks: List[Dict]) -> int:
  202. """Calculate overall PR complexity score (1-10)."""
  203. score = 0
  204. # File count contribution (max 3 points)
  205. file_count = len(files)
  206. if file_count > 20:
  207. score += 3
  208. elif file_count > 10:
  209. score += 2
  210. elif file_count > 5:
  211. score += 1
  212. # Total changes contribution (max 3 points)
  213. total_changes = sum(f.get("additions", 0) + f.get("deletions", 0) for f in files)
  214. if total_changes > 500:
  215. score += 3
  216. elif total_changes > 200:
  217. score += 2
  218. elif total_changes > 50:
  219. score += 1
  220. # Risk severity contribution (max 4 points)
  221. critical_risks = sum(1 for r in all_risks if r["severity"] == "critical")
  222. high_risks = sum(1 for r in all_risks if r["severity"] == "high")
  223. score += min(2, critical_risks)
  224. score += min(2, high_risks)
  225. return min(10, max(1, score))
  226. def analyze_commit_messages(repo_path: Path, base: str, head: str) -> Dict:
  227. """Analyze commit messages in the PR."""
  228. success, output = run_git_command(
  229. ["git", "log", "--oneline", f"{base}...{head}"],
  230. repo_path
  231. )
  232. if not success or not output:
  233. return {"commits": 0, "issues": []}
  234. commits = output.strip().split("\n")
  235. issues = []
  236. for commit in commits:
  237. if len(commit) < 10:
  238. continue
  239. # Check for conventional commit format
  240. message = commit[8:] if len(commit) > 8 else commit # Skip hash
  241. if not re.match(r"^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?:", message):
  242. issues.append({
  243. "commit": commit[:7],
  244. "issue": "Does not follow conventional commit format"
  245. })
  246. if len(message) > 72:
  247. issues.append({
  248. "commit": commit[:7],
  249. "issue": "Commit message exceeds 72 characters"
  250. })
  251. return {
  252. "commits": len(commits),
  253. "issues": issues
  254. }
  255. def analyze_pr(
  256. repo_path: Path,
  257. base: str = "main",
  258. head: str = "HEAD"
  259. ) -> Dict:
  260. """Perform complete PR analysis."""
  261. # Get changed files
  262. changed_files = get_changed_files(repo_path, base, head)
  263. if not changed_files:
  264. return {
  265. "status": "no_changes",
  266. "message": "No changes detected between branches"
  267. }
  268. # Analyze each file
  269. all_risks = []
  270. file_analyses = []
  271. for file_info in changed_files:
  272. filepath = file_info["path"]
  273. category, weight = categorize_file(filepath)
  274. # Get diff for the file
  275. diff = get_file_diff(repo_path, filepath, base, head)
  276. changes = count_changes(diff)
  277. risks = analyze_diff_for_risks(diff, filepath)
  278. all_risks.extend(risks)
  279. file_analyses.append({
  280. "path": filepath,
  281. "status": file_info["status"],
  282. "category": category,
  283. "priority_weight": weight,
  284. "additions": changes["additions"],
  285. "deletions": changes["deletions"],
  286. "risks": risks
  287. })
  288. # Sort by priority (highest first)
  289. file_analyses.sort(key=lambda x: (-x["priority_weight"], x["path"]))
  290. # Analyze commits
  291. commit_analysis = analyze_commit_messages(repo_path, base, head)
  292. # Calculate metrics
  293. complexity = calculate_complexity_score(file_analyses, all_risks)
  294. total_additions = sum(f["additions"] for f in file_analyses)
  295. total_deletions = sum(f["deletions"] for f in file_analyses)
  296. return {
  297. "status": "analyzed",
  298. "summary": {
  299. "files_changed": len(file_analyses),
  300. "total_additions": total_additions,
  301. "total_deletions": total_deletions,
  302. "complexity_score": complexity,
  303. "complexity_label": get_complexity_label(complexity),
  304. "commits": commit_analysis["commits"]
  305. },
  306. "risks": {
  307. "critical": [r for r in all_risks if r["severity"] == "critical"],
  308. "high": [r for r in all_risks if r["severity"] == "high"],
  309. "medium": [r for r in all_risks if r["severity"] == "medium"],
  310. "low": [r for r in all_risks if r["severity"] == "low"]
  311. },
  312. "files": file_analyses,
  313. "commit_issues": commit_analysis["issues"],
  314. "review_order": [f["path"] for f in file_analyses[:10]] # Top 10 priority files
  315. }
  316. def get_complexity_label(score: int) -> str:
  317. """Get human-readable complexity label."""
  318. if score <= 2:
  319. return "Simple"
  320. elif score <= 4:
  321. return "Moderate"
  322. elif score <= 6:
  323. return "Complex"
  324. elif score <= 8:
  325. return "Very Complex"
  326. else:
  327. return "Critical"
  328. def print_report(analysis: Dict) -> None:
  329. """Print human-readable analysis report."""
  330. if analysis["status"] == "no_changes":
  331. print("No changes detected.")
  332. return
  333. summary = analysis["summary"]
  334. risks = analysis["risks"]
  335. print("=" * 60)
  336. print("PR ANALYSIS REPORT")
  337. print("=" * 60)
  338. print(f"\nComplexity: {summary['complexity_score']}/10 ({summary['complexity_label']})")
  339. print(f"Files Changed: {summary['files_changed']}")
  340. print(f"Lines: +{summary['total_additions']} / -{summary['total_deletions']}")
  341. print(f"Commits: {summary['commits']}")
  342. # Risk summary
  343. print("\n--- RISK SUMMARY ---")
  344. print(f"Critical: {len(risks['critical'])}")
  345. print(f"High: {len(risks['high'])}")
  346. print(f"Medium: {len(risks['medium'])}")
  347. print(f"Low: {len(risks['low'])}")
  348. # Critical and high risks details
  349. if risks["critical"]:
  350. print("\n--- CRITICAL RISKS ---")
  351. for risk in risks["critical"]:
  352. print(f" [{risk['file']}] {risk['message']} (x{risk['count']})")
  353. if risks["high"]:
  354. print("\n--- HIGH RISKS ---")
  355. for risk in risks["high"]:
  356. print(f" [{risk['file']}] {risk['message']} (x{risk['count']})")
  357. # Commit message issues
  358. if analysis["commit_issues"]:
  359. print("\n--- COMMIT MESSAGE ISSUES ---")
  360. for issue in analysis["commit_issues"][:5]:
  361. print(f" {issue['commit']}: {issue['issue']}")
  362. # Review order
  363. print("\n--- SUGGESTED REVIEW ORDER ---")
  364. for i, filepath in enumerate(analysis["review_order"], 1):
  365. file_info = next(f for f in analysis["files"] if f["path"] == filepath)
  366. print(f" {i}. [{file_info['category'].upper()}] {filepath}")
  367. print("\n" + "=" * 60)
  368. def main():
  369. parser = argparse.ArgumentParser(
  370. description="Analyze pull request for review complexity and risks"
  371. )
  372. parser.add_argument(
  373. "repo_path",
  374. nargs="?",
  375. default=".",
  376. help="Path to git repository (default: current directory)"
  377. )
  378. parser.add_argument(
  379. "--base", "-b",
  380. default="main",
  381. help="Base branch for comparison (default: main)"
  382. )
  383. parser.add_argument(
  384. "--head",
  385. default="HEAD",
  386. help="Head branch/commit for comparison (default: HEAD)"
  387. )
  388. parser.add_argument(
  389. "--json",
  390. action="store_true",
  391. help="Output in JSON format"
  392. )
  393. parser.add_argument(
  394. "--output", "-o",
  395. help="Write output to file"
  396. )
  397. args = parser.parse_args()
  398. repo_path = Path(args.repo_path).resolve()
  399. if not (repo_path / ".git").exists():
  400. print(f"Error: {repo_path} is not a git repository", file=sys.stderr)
  401. sys.exit(1)
  402. analysis = analyze_pr(repo_path, args.base, args.head)
  403. if args.json:
  404. output = json.dumps(analysis, indent=2)
  405. if args.output:
  406. with open(args.output, "w") as f:
  407. f.write(output)
  408. print(f"Results written to {args.output}")
  409. else:
  410. print(output)
  411. else:
  412. print_report(analysis)
  413. if __name__ == "__main__":
  414. main()