2026-01-10 05:26:35 +07:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Pull Request Review Script
|
|
|
|
|
Reviews all pull requests and creates a comprehensive summary
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
2026-02-26 12:54:45 +00:00
|
|
|
# ⚡ Bolt: Global cache for branch metadata to avoid redundant git calls.
|
|
|
|
|
BRANCH_METADATA_CACHE = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_git_version():
|
|
|
|
|
"""⚡ Bolt: Get git version to check for ahead-behind support (2.41+)."""
|
|
|
|
|
result = run_command(["git", "--version"])
|
|
|
|
|
if result and result.returncode == 0:
|
|
|
|
|
try:
|
|
|
|
|
# "git version 2.45.1" -> [2, 45, 1]
|
|
|
|
|
version_str = result.stdout.strip().split()[-1]
|
|
|
|
|
return [int(x) for x in version_str.split('.')[:3]]
|
|
|
|
|
except (ValueError, IndexError):
|
|
|
|
|
pass
|
|
|
|
|
return [0, 0, 0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_branch_metadata(base_branch="main"):
|
|
|
|
|
"""⚡ Bolt: Fetch metadata for all remote branches in bulk (O(1) subprocess calls)."""
|
|
|
|
|
global BRANCH_METADATA_CACHE
|
|
|
|
|
if BRANCH_METADATA_CACHE:
|
|
|
|
|
return BRANCH_METADATA_CACHE
|
|
|
|
|
|
|
|
|
|
version = get_git_version()
|
|
|
|
|
# ahead-behind atom was added in git 2.41
|
|
|
|
|
supports_ahead_behind = version >= [2, 41, 0]
|
|
|
|
|
|
|
|
|
|
# ⚡ Bolt: Use objectname:short to match 'git log --oneline' format (hash + subject)
|
|
|
|
|
fmt = "%(refname:short)|%(committerdate:iso8601)|%(objectname:short) %(subject)"
|
|
|
|
|
if supports_ahead_behind:
|
|
|
|
|
fmt = f"%(ahead-behind:{base_branch})|{fmt}"
|
|
|
|
|
|
|
|
|
|
# Scan all remote branches under refs/remotes/
|
|
|
|
|
result = run_command(["git", "for-each-ref", f"--format={fmt}", "refs/remotes/"])
|
|
|
|
|
if not result or result.returncode != 0:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
for line in result.stdout.strip().split('\n'):
|
|
|
|
|
if not line: continue
|
|
|
|
|
parts = line.split('|', 3 if supports_ahead_behind else 2)
|
|
|
|
|
|
|
|
|
|
if supports_ahead_behind:
|
|
|
|
|
# format: "ahead behind|ref|date|subject"
|
|
|
|
|
counts, ref, date, subject = parts
|
|
|
|
|
ahead, behind = map(int, counts.split())
|
|
|
|
|
else:
|
|
|
|
|
# fallback: "ref|date|subject"
|
|
|
|
|
ref, date, subject = parts
|
|
|
|
|
ahead, behind = None, None
|
|
|
|
|
|
|
|
|
|
# Filter out HEAD, main, and the remote name itself
|
|
|
|
|
if "/HEAD" in ref or ref.endswith("/main") or ref == "origin":
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
BRANCH_METADATA_CACHE[ref] = {
|
|
|
|
|
"ahead": ahead,
|
|
|
|
|
"behind": behind,
|
|
|
|
|
"date": date,
|
|
|
|
|
"subject": subject
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return BRANCH_METADATA_CACHE
|
|
|
|
|
|
2026-01-10 05:26:35 +07:00
|
|
|
|
|
|
|
|
def run_command(cmd, capture_output=True):
|
|
|
|
|
"""Run a command and return the result."""
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
cmd,
|
|
|
|
|
cwd=REPO_ROOT,
|
|
|
|
|
capture_output=capture_output,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=30,
|
|
|
|
|
encoding='utf-8',
|
|
|
|
|
errors='replace'
|
|
|
|
|
)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error running command: {e}", file=sys.stderr)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_prs_via_gh_cli():
|
|
|
|
|
"""Get PRs using GitHub CLI."""
|
|
|
|
|
result = run_command(["gh", "pr", "list", "--state", "all", "--json", "number,title,state,author,createdAt,updatedAt,headRefName,baseRefName,isDraft,labels"])
|
|
|
|
|
if result and result.returncode == 0:
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(result.stdout)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
return []
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_prs_via_git():
|
2026-02-26 12:54:45 +00:00
|
|
|
"""⚡ Bolt: Get PR information via git branches using cached metadata with fallback."""
|
|
|
|
|
metadata = fetch_branch_metadata()
|
2026-01-10 05:26:35 +07:00
|
|
|
|
2026-02-26 12:54:45 +00:00
|
|
|
# Check if we have ahead/behind data (Git 2.41+)
|
|
|
|
|
has_ahead_behind = any(d["ahead"] is not None for d in metadata.values())
|
|
|
|
|
|
|
|
|
|
if not has_ahead_behind:
|
|
|
|
|
# ⚡ Bolt: Fallback to original logic for old git versions
|
|
|
|
|
result = run_command(["git", "branch", "-r", "--no-merged", "main"])
|
|
|
|
|
open_branches = []
|
|
|
|
|
if result and result.returncode == 0:
|
|
|
|
|
open_branches = [b.strip() for b in result.stdout.strip().split("\n")
|
|
|
|
|
if b.strip() and "origin/main" not in b and "HEAD" not in b]
|
|
|
|
|
|
|
|
|
|
result_merged = run_command(["git", "branch", "-r", "--merged", "main"])
|
|
|
|
|
merged_branches = []
|
|
|
|
|
if result_merged and result_merged.returncode == 0:
|
|
|
|
|
merged_branches = [b.strip() for b in result_merged.stdout.strip().split("\n")
|
|
|
|
|
if b.strip() and "origin/main" not in b and "HEAD" not in b]
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"open": sorted(open_branches),
|
|
|
|
|
"merged": sorted(merged_branches)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
open_branches = []
|
2026-01-10 05:26:35 +07:00
|
|
|
merged_branches = []
|
|
|
|
|
|
2026-02-26 12:54:45 +00:00
|
|
|
for ref, data in metadata.items():
|
|
|
|
|
if data["ahead"] > 0:
|
|
|
|
|
open_branches.append(ref)
|
|
|
|
|
else:
|
|
|
|
|
merged_branches.append(ref)
|
|
|
|
|
|
2026-01-10 05:26:35 +07:00
|
|
|
return {
|
2026-02-26 12:54:45 +00:00
|
|
|
"open": sorted(open_branches),
|
|
|
|
|
"merged": sorted(merged_branches)
|
2026-01-10 05:26:35 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def analyze_branch_name(branch_name):
|
|
|
|
|
"""Analyze branch name to extract PR information."""
|
|
|
|
|
branch = branch_name.replace("origin/", "")
|
|
|
|
|
|
|
|
|
|
info = {
|
|
|
|
|
"type": "unknown",
|
|
|
|
|
"category": "other",
|
|
|
|
|
"description": branch
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Categorize branches
|
|
|
|
|
if branch.startswith("Cursor/"):
|
|
|
|
|
info["type"] = "cursor"
|
|
|
|
|
info["category"] = "ai-generated"
|
|
|
|
|
info["description"] = branch.replace("Cursor/A6-9V/", "")
|
|
|
|
|
elif branch.startswith("copilot/"):
|
|
|
|
|
info["type"] = "copilot"
|
|
|
|
|
info["category"] = "ai-generated"
|
|
|
|
|
info["description"] = branch.replace("copilot/", "")
|
|
|
|
|
elif branch.startswith("bolt-"):
|
|
|
|
|
info["type"] = "bolt"
|
|
|
|
|
info["category"] = "optimization"
|
|
|
|
|
info["description"] = branch.replace("bolt-", "")
|
|
|
|
|
elif branch.startswith("feat/"):
|
|
|
|
|
info["type"] = "feature"
|
|
|
|
|
info["category"] = "feature"
|
|
|
|
|
info["description"] = branch.replace("feat/", "")
|
|
|
|
|
elif branch.startswith("feature/"):
|
|
|
|
|
info["type"] = "feature"
|
|
|
|
|
info["category"] = "feature"
|
|
|
|
|
info["description"] = branch.replace("feature/", "")
|
|
|
|
|
|
|
|
|
|
return info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_branch_info(branch_name):
|
2026-02-26 12:54:45 +00:00
|
|
|
"""⚡ Bolt: Get detailed information about a branch using cached metadata."""
|
|
|
|
|
metadata = fetch_branch_metadata()
|
|
|
|
|
data = metadata.get(branch_name, {})
|
2026-01-10 05:26:35 +07:00
|
|
|
|
2026-02-26 12:54:45 +00:00
|
|
|
branch = branch_name.replace("origin/", "")
|
|
|
|
|
ahead = data.get("ahead")
|
2026-01-10 05:26:35 +07:00
|
|
|
|
2026-02-26 12:54:45 +00:00
|
|
|
# ⚡ Bolt: Check if we have the optimized metadata (ahead count is not None)
|
|
|
|
|
if ahead is None:
|
|
|
|
|
# Fallback for old git or missing metadata: use original slow path
|
|
|
|
|
result = run_command(["git", "log", "--oneline", f"main..{branch_name}"])
|
|
|
|
|
commit_count = 0
|
|
|
|
|
commits = []
|
|
|
|
|
if result and result.returncode == 0:
|
|
|
|
|
commits = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()]
|
|
|
|
|
commit_count = len(commits)
|
|
|
|
|
|
|
|
|
|
result_date = run_command(["git", "log", "-1", "--format=%ci", branch_name])
|
|
|
|
|
last_commit = None
|
|
|
|
|
if result_date and result_date.returncode == 0 and result_date.stdout.strip():
|
|
|
|
|
last_commit = result_date.stdout.strip()
|
|
|
|
|
else:
|
|
|
|
|
# Optimized path using cached metadata
|
|
|
|
|
commit_count = ahead
|
|
|
|
|
last_commit = data.get("date")
|
|
|
|
|
|
|
|
|
|
commits = []
|
|
|
|
|
if commit_count > 0:
|
|
|
|
|
# If we only have 1 commit ahead, we can use the cached subject (which includes hash)
|
|
|
|
|
if commit_count == 1 and data.get("subject"):
|
|
|
|
|
commits.append(data["subject"])
|
|
|
|
|
else:
|
|
|
|
|
# ⚡ Bolt: Still need git log for multiple commits to match original behavior
|
|
|
|
|
result = run_command(["git", "log", "--oneline", "-n", "5", f"main..{branch_name}"])
|
|
|
|
|
if result and result.returncode == 0:
|
|
|
|
|
commits = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()]
|
|
|
|
|
|
2026-01-10 05:26:35 +07:00
|
|
|
return {
|
|
|
|
|
"branch": branch,
|
|
|
|
|
"full_name": branch_name,
|
|
|
|
|
"commit_count": commit_count,
|
2026-02-26 12:54:45 +00:00
|
|
|
"commits": commits[:5],
|
2026-01-10 05:26:35 +07:00
|
|
|
"last_commit_date": last_commit
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
"""Main review function."""
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print("PULL REQUEST REVIEW")
|
|
|
|
|
print(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
# Try GitHub CLI first
|
|
|
|
|
prs = get_prs_via_gh_cli()
|
|
|
|
|
|
|
|
|
|
if prs is not None:
|
|
|
|
|
print(f"Found {len(prs)} pull requests via GitHub CLI")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
# Group by state
|
|
|
|
|
by_state = defaultdict(list)
|
|
|
|
|
for pr in prs:
|
|
|
|
|
by_state[pr.get("state", "unknown")].append(pr)
|
|
|
|
|
|
|
|
|
|
print("Pull Requests by State:")
|
|
|
|
|
for state, pr_list in sorted(by_state.items()):
|
|
|
|
|
print(f" {state.upper()}: {len(pr_list)}")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
# Show open PRs
|
|
|
|
|
open_prs = by_state.get("OPEN", [])
|
|
|
|
|
if open_prs:
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print("OPEN PULL REQUESTS")
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
for pr in open_prs:
|
|
|
|
|
print(f"\nPR #{pr.get('number', 'N/A')}: {pr.get('title', 'No title')}")
|
|
|
|
|
print(f" Author: {pr.get('author', {}).get('login', 'Unknown')}")
|
|
|
|
|
print(f" Branch: {pr.get('headRefName', 'N/A')} -> {pr.get('baseRefName', 'main')}")
|
|
|
|
|
print(f" Created: {pr.get('createdAt', 'N/A')}")
|
|
|
|
|
print(f" Updated: {pr.get('updatedAt', 'N/A')}")
|
|
|
|
|
print(f" Draft: {'Yes' if pr.get('isDraft') else 'No'}")
|
|
|
|
|
labels = [l.get('name') for l in pr.get('labels', [])]
|
|
|
|
|
if labels:
|
|
|
|
|
print(f" Labels: {', '.join(labels)}")
|
|
|
|
|
|
|
|
|
|
# Show merged PRs
|
|
|
|
|
merged_prs = by_state.get("MERGED", [])
|
|
|
|
|
if merged_prs:
|
|
|
|
|
print("\n" + "=" * 80)
|
|
|
|
|
print(f"MERGED PULL REQUESTS ({len(merged_prs)} total)")
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print(f"\nShowing last 10 merged PRs:")
|
|
|
|
|
for pr in merged_prs[-10:]:
|
|
|
|
|
print(f" PR #{pr.get('number', 'N/A')}: {pr.get('title', 'No title')}")
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
# Fallback to git branch analysis
|
|
|
|
|
print("GitHub CLI not available, analyzing branches...")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
branch_info = get_prs_via_git()
|
|
|
|
|
|
|
|
|
|
open_branches = branch_info["open"]
|
|
|
|
|
merged_branches = branch_info["merged"]
|
|
|
|
|
|
|
|
|
|
print(f"Open branches (potential PRs): {len(open_branches)}")
|
|
|
|
|
print(f"Merged branches (completed PRs): {len(merged_branches)}")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
# Categorize open branches
|
|
|
|
|
categories = defaultdict(list)
|
|
|
|
|
for branch in open_branches:
|
|
|
|
|
info = analyze_branch_name(branch)
|
|
|
|
|
categories[info["category"]].append((branch, info))
|
|
|
|
|
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print("OPEN BRANCHES (Potential Pull Requests)")
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
for category, branches in sorted(categories.items()):
|
|
|
|
|
print(f"{category.upper()}: {len(branches)} branches")
|
|
|
|
|
for branch, info in branches[:10]: # Show first 10
|
|
|
|
|
branch_details = get_branch_info(branch)
|
|
|
|
|
print(f" - {info['description']}")
|
|
|
|
|
print(f" Branch: {branch_details['branch']}")
|
|
|
|
|
print(f" Commits: {branch_details['commit_count']}")
|
|
|
|
|
if branch_details['last_commit_date']:
|
|
|
|
|
print(f" Last commit: {branch_details['last_commit_date']}")
|
|
|
|
|
if len(branches) > 10:
|
|
|
|
|
print(f" ... and {len(branches) - 10} more")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print("MERGED BRANCHES (Completed Pull Requests)")
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print(f"\nTotal merged: {len(merged_branches)}")
|
|
|
|
|
print("\nRecent merged branches:")
|
|
|
|
|
for branch in merged_branches[:20]:
|
|
|
|
|
info = analyze_branch_name(branch)
|
|
|
|
|
print(f" - {info['description']}")
|
|
|
|
|
|
|
|
|
|
print("\n" + "=" * 80)
|
|
|
|
|
print("REVIEW COMPLETE")
|
|
|
|
|
print("=" * 80)
|
|
|
|
|
print("\nNote: GitHub doesn't support 'pinning' pull requests directly.")
|
|
|
|
|
print("Consider:")
|
|
|
|
|
print("1. Creating a tracking issue for important PRs")
|
|
|
|
|
print("2. Using labels to categorize PRs")
|
|
|
|
|
print("3. Adding PRs to project boards")
|
|
|
|
|
print("4. Creating a PR summary document")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|