Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29bc905643 |
337
bin/pr_backlog_analyzer.py
Normal file
337
bin/pr_backlog_analyzer.py
Normal file
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR Backlog Analyzer for timmy-config
|
||||
Issue #1470: process: Address timmy-config PR backlog (9 PRs - highest in org)
|
||||
|
||||
Analyzes open PRs and provides recommendations for managing the backlog.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Configuration
|
||||
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
TOKEN_PATH = os.path.expanduser("~/.config/gitea/token")
|
||||
ORG = "Timmy_Foundation"
|
||||
REPO = "timmy-config"
|
||||
|
||||
|
||||
class PRBacklogAnalyzer:
|
||||
"""Analyze and manage PR backlog."""
|
||||
|
||||
def __init__(self):
|
||||
self.token = self._load_token()
|
||||
|
||||
def _load_token(self) -> str:
|
||||
"""Load Gitea API token."""
|
||||
try:
|
||||
with open(TOKEN_PATH, "r") as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: Token not found at {TOKEN_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
def _api_request(self, endpoint: str) -> Any:
|
||||
"""Make authenticated Gitea API request."""
|
||||
url = f"{GITEA_BASE}{endpoint}"
|
||||
headers = {"Authorization": f"token {self.token}"}
|
||||
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
error_body = e.read().decode() if e.fp else "No error body"
|
||||
print(f"API Error {e.code}: {error_body}")
|
||||
return None
|
||||
|
||||
def get_open_prs(self) -> List[Dict]:
|
||||
"""Get open PRs for timmy-config."""
|
||||
endpoint = f"/repos/{ORG}/{REPO}/pulls?state=open"
|
||||
prs = self._api_request(endpoint)
|
||||
return prs if isinstance(prs, list) else []
|
||||
|
||||
def get_pr_details(self, pr_number: int) -> Optional[Dict]:
|
||||
"""Get detailed information about a PR."""
|
||||
endpoint = f"/repos/{ORG}/{REPO}/pulls/{pr_number}"
|
||||
return self._api_request(endpoint)
|
||||
|
||||
def get_pr_reviews(self, pr_number: int) -> List[Dict]:
|
||||
"""Get reviews for a PR."""
|
||||
endpoint = f"/repos/{ORG}/{REPO}/pulls/{pr_number}/reviews"
|
||||
reviews = self._api_request(endpoint)
|
||||
return reviews if isinstance(reviews, list) else []
|
||||
|
||||
def analyze_pr(self, pr: Dict) -> Dict[str, Any]:
|
||||
"""Analyze a single PR."""
|
||||
pr_number = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
pr_author = pr["user"]["login"]
|
||||
created_at = datetime.fromisoformat(pr["created_at"].replace('Z', '+00:00'))
|
||||
updated_at = datetime.fromisoformat(pr["updated_at"].replace('Z', '+00:00'))
|
||||
|
||||
# Get detailed PR info
|
||||
pr_details = self.get_pr_details(pr_number)
|
||||
reviews = self.get_pr_reviews(pr_number)
|
||||
|
||||
# Calculate age
|
||||
age_days = (datetime.now() - created_at).days
|
||||
days_since_update = (datetime.now() - updated_at).days
|
||||
|
||||
# Check for reviewers
|
||||
has_reviewers = len(pr.get("requested_reviewers", [])) > 0
|
||||
has_reviews = len(reviews) > 0
|
||||
|
||||
# Check for changes
|
||||
additions = pr_details.get("additions", 0) if pr_details else 0
|
||||
deletions = pr_details.get("deletions", 0) if pr_details else 0
|
||||
changed_files = pr_details.get("changed_files", 0) if pr_details else 0
|
||||
|
||||
# Determine status
|
||||
if age_days > 30 and days_since_update > 7:
|
||||
status = "stale"
|
||||
elif not has_reviewers and not has_reviews:
|
||||
status = "needs_reviewer"
|
||||
elif additions == 0 and deletions == 0:
|
||||
status = "zombie"
|
||||
elif has_reviews:
|
||||
status = "reviewed"
|
||||
else:
|
||||
status = "pending"
|
||||
|
||||
return {
|
||||
"number": pr_number,
|
||||
"title": pr_title,
|
||||
"author": pr_author,
|
||||
"created": pr["created_at"],
|
||||
"updated": pr["updated_at"],
|
||||
"age_days": age_days,
|
||||
"days_since_update": days_since_update,
|
||||
"has_reviewers": has_reviewers,
|
||||
"has_reviews": has_reviews,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"changed_files": changed_files,
|
||||
"status": status,
|
||||
"url": pr["html_url"]
|
||||
}
|
||||
|
||||
def analyze_backlog(self) -> Dict[str, Any]:
|
||||
"""Analyze the entire PR backlog."""
|
||||
prs = self.get_open_prs()
|
||||
|
||||
analysis = {
|
||||
"total_prs": len(prs),
|
||||
"by_status": {},
|
||||
"by_author": {},
|
||||
"by_age": {
|
||||
"0-7_days": 0,
|
||||
"8-30_days": 0,
|
||||
"31-90_days": 0,
|
||||
"90+_days": 0
|
||||
},
|
||||
"recommendations": [],
|
||||
"prs": []
|
||||
}
|
||||
|
||||
for pr in prs:
|
||||
pr_analysis = self.analyze_pr(pr)
|
||||
analysis["prs"].append(pr_analysis)
|
||||
|
||||
# Count by status
|
||||
status = pr_analysis["status"]
|
||||
analysis["by_status"][status] = analysis["by_status"].get(status, 0) + 1
|
||||
|
||||
# Count by author
|
||||
author = pr_analysis["author"]
|
||||
analysis["by_author"][author] = analysis["by_author"].get(author, 0) + 1
|
||||
|
||||
# Count by age
|
||||
age = pr_analysis["age_days"]
|
||||
if age <= 7:
|
||||
analysis["by_age"]["0-7_days"] += 1
|
||||
elif age <= 30:
|
||||
analysis["by_age"]["8-30_days"] += 1
|
||||
elif age <= 90:
|
||||
analysis["by_age"]["31-90_days"] += 1
|
||||
else:
|
||||
analysis["by_age"]["90+_days"] += 1
|
||||
|
||||
# Generate recommendations
|
||||
if analysis["by_status"].get("stale", 0) > 0:
|
||||
analysis["recommendations"].append(
|
||||
f"Close {analysis['by_status']['stale']} stale PRs (>30 days old, >7 days since update)"
|
||||
)
|
||||
|
||||
if analysis["by_status"].get("zombie", 0) > 0:
|
||||
analysis["recommendations"].append(
|
||||
f"Close {analysis['by_status']['zombie']} zombie PRs (no changes)"
|
||||
)
|
||||
|
||||
if analysis["by_status"].get("needs_reviewer", 0) > 0:
|
||||
analysis["recommendations"].append(
|
||||
f"Assign reviewers to {analysis['by_status']['needs_reviewer']} PRs"
|
||||
)
|
||||
|
||||
if analysis["by_status"].get("reviewed", 0) > 0:
|
||||
analysis["recommendations"].append(
|
||||
f"Merge {analysis['by_status']['reviewed']} reviewed PRs"
|
||||
)
|
||||
|
||||
return analysis
|
||||
|
||||
def generate_report(self, analysis: Dict[str, Any]) -> str:
|
||||
"""Generate a markdown report."""
|
||||
report = f"# timmy-config PR Backlog Analysis\n\n"
|
||||
report += f"Generated: {datetime.now().isoformat()}\n\n"
|
||||
|
||||
report += "## Summary\n"
|
||||
report += f"- **Total open PRs:** {analysis['total_prs']}\n"
|
||||
report += f"- **Stale PRs (>30 days):** {analysis['by_status'].get('stale', 0)}\n"
|
||||
report += f"- **Zombie PRs (no changes):** {analysis['by_status'].get('zombie', 0)}\n"
|
||||
report += f"- **Needs reviewer:** {analysis['by_status'].get('needs_reviewer', 0)}\n"
|
||||
report += f"- **Reviewed:** {analysis['by_status'].get('reviewed', 0)}\n\n"
|
||||
|
||||
report += "## Age Distribution\n"
|
||||
for age_range, count in analysis['by_age'].items():
|
||||
report += f"- **{age_range}:** {count} PRs\n"
|
||||
|
||||
report += "\n## Author Distribution\n"
|
||||
for author, count in sorted(analysis['by_author'].items(), key=lambda x: x[1], reverse=True):
|
||||
report += f"- **{author}:** {count} PRs\n"
|
||||
|
||||
report += "\n## Recommendations\n"
|
||||
for i, rec in enumerate(analysis['recommendations'], 1):
|
||||
report += f"{i}. {rec}\n"
|
||||
|
||||
report += "\n## Detailed PR Analysis\n"
|
||||
for pr in analysis['prs']:
|
||||
status_icon = {
|
||||
'stale': '🔴',
|
||||
'zombie': '💀',
|
||||
'needs_reviewer': '⚠️',
|
||||
'reviewed': '✅',
|
||||
'pending': '🔵'
|
||||
}.get(pr['status'], '❓')
|
||||
|
||||
report += f"\n### {status_icon} PR #{pr['number']}: {pr['title']}\n"
|
||||
report += f"- **Author:** {pr['author']}\n"
|
||||
report += f"- **Status:** {pr['status']}\n"
|
||||
report += f"- **Age:** {pr['age_days']} days\n"
|
||||
report += f"- **Days since update:** {pr['days_since_update']}\n"
|
||||
report += f"- **Changes:** +{pr['additions']}/-{pr['deletions']} ({pr['changed_files']} files)\n"
|
||||
report += f"- **Reviewers:** {'Yes' if pr['has_reviewers'] else 'No'}\n"
|
||||
report += f"- **Reviews:** {'Yes' if pr['has_reviews'] else 'No'}\n"
|
||||
report += f"- **URL:** {pr['url']}\n"
|
||||
|
||||
return report
|
||||
|
||||
def generate_action_plan(self, analysis: Dict[str, Any]) -> str:
|
||||
"""Generate an action plan for the backlog."""
|
||||
plan = f"# Action Plan for timmy-config PR Backlog\n\n"
|
||||
plan += f"Generated: {datetime.now().isoformat()}\n\n"
|
||||
|
||||
# Priority 1: Close stale and zombie PRs
|
||||
stale_count = analysis['by_status'].get('stale', 0)
|
||||
zombie_count = analysis['by_status'].get('zombie', 0)
|
||||
|
||||
if stale_count > 0 or zombie_count > 0:
|
||||
plan += "## Priority 1: Close Stale and Zombie PRs\n"
|
||||
plan += "These PRs are not adding value and should be closed:\n\n"
|
||||
|
||||
for pr in analysis['prs']:
|
||||
if pr['status'] in ['stale', 'zombie']:
|
||||
plan += f"- [ ] Close PR #{pr['number']}: {pr['title']}\n"
|
||||
plan += f" - Author: {pr['author']}\n"
|
||||
plan += f" - Age: {pr['age_days']} days\n"
|
||||
plan += f" - URL: {pr['url']}\n\n"
|
||||
|
||||
# Priority 2: Assign reviewers
|
||||
needs_reviewer_count = analysis['by_status'].get('needs_reviewer', 0)
|
||||
|
||||
if needs_reviewer_count > 0:
|
||||
plan += "## Priority 2: Assign Reviewers\n"
|
||||
plan += "These PRs need reviewers assigned:\n\n"
|
||||
|
||||
for pr in analysis['prs']:
|
||||
if pr['status'] == 'needs_reviewer':
|
||||
plan += f"- [ ] Assign reviewer to PR #{pr['number']}: {pr['title']}\n"
|
||||
plan += f" - Author: {pr['author']}\n"
|
||||
plan += f" - URL: {pr['url']}\n\n"
|
||||
|
||||
# Priority 3: Merge reviewed PRs
|
||||
reviewed_count = analysis['by_status'].get('reviewed', 0)
|
||||
|
||||
if reviewed_count > 0:
|
||||
plan += "## Priority 3: Merge Reviewed PRs\n"
|
||||
plan += "These PRs have been reviewed and can be merged:\n\n"
|
||||
|
||||
for pr in analysis['prs']:
|
||||
if pr['status'] == 'reviewed':
|
||||
plan += f"- [ ] Merge PR #{pr['number']}: {pr['title']}\n"
|
||||
plan += f" - Author: {pr['author']}\n"
|
||||
plan += f" - URL: {pr['url']}\n\n"
|
||||
|
||||
# Priority 4: Review pending PRs
|
||||
pending_count = analysis['by_status'].get('pending', 0)
|
||||
|
||||
if pending_count > 0:
|
||||
plan += "## Priority 4: Review Pending PRs\n"
|
||||
plan += "These PRs need to be reviewed:\n\n"
|
||||
|
||||
for pr in analysis['prs']:
|
||||
if pr['status'] == 'pending':
|
||||
plan += f"- [ ] Review PR #{pr['number']}: {pr['title']}\n"
|
||||
plan += f" - Author: {pr['author']}\n"
|
||||
plan += f" - URL: {pr['url']}\n\n"
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="PR Backlog Analyzer for timmy-config")
|
||||
parser.add_argument("--analyze", action="store_true", help="Analyze PR backlog")
|
||||
parser.add_argument("--report", action="store_true", help="Generate report")
|
||||
parser.add_argument("--action-plan", action="store_true", help="Generate action plan")
|
||||
parser.add_argument("--json", action="store_true", help="Output JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
analyzer = PRBacklogAnalyzer()
|
||||
|
||||
if args.analyze or args.report or args.action_plan or args.json:
|
||||
analysis = analyzer.analyze_backlog()
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(analysis, indent=2))
|
||||
elif args.report:
|
||||
report = analyzer.generate_report(analysis)
|
||||
print(report)
|
||||
elif args.action_plan:
|
||||
plan = analyzer.generate_action_plan(analysis)
|
||||
print(plan)
|
||||
else:
|
||||
# Default: show summary
|
||||
print(f"timmy-config PR Backlog Analysis")
|
||||
print(f" Total open PRs: {analysis['total_prs']}")
|
||||
print(f" Stale PRs: {analysis['by_status'].get('stale', 0)}")
|
||||
print(f" Zombie PRs: {analysis['by_status'].get('zombie', 0)}")
|
||||
print(f" Needs reviewer: {analysis['by_status'].get('needs_reviewer', 0)}")
|
||||
print(f" Reviewed: {analysis['by_status'].get('reviewed', 0)}")
|
||||
print(f" Pending: {analysis['by_status'].get('pending', 0)}")
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
133
docs/pr-backlog-analyzer.md
Normal file
133
docs/pr-backlog-analyzer.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# PR Backlog Analyzer
|
||||
|
||||
**Issue:** #1470 - process: Address timmy-config PR backlog (9 PRs - highest in org)
|
||||
|
||||
## Overview
|
||||
|
||||
This tool analyzes the PR backlog in timmy-config and provides recommendations for managing it.
|
||||
|
||||
## Problem
|
||||
|
||||
timmy-config has the highest PR backlog in the organization:
|
||||
- 94 open issues
|
||||
- 9 open PRs (9 of 14 org-wide PRs)
|
||||
|
||||
## Solution
|
||||
|
||||
### PR Backlog Analyzer (`bin/pr_backlog_analyzer.py`)
|
||||
Tool to analyze and manage the PR backlog.
|
||||
|
||||
**Features:**
|
||||
- Analyze open PRs in timmy-config
|
||||
- Identify stale and zombie PRs
|
||||
- Generate action plans
|
||||
- Provide recommendations
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Analyze backlog
|
||||
python bin/pr_backlog_analyzer.py --analyze
|
||||
|
||||
# Generate report
|
||||
python bin/pr_backlog_analyzer.py --report
|
||||
|
||||
# Generate action plan
|
||||
python bin/pr_backlog_analyzer.py --action-plan
|
||||
|
||||
# JSON output
|
||||
python bin/pr_backlog_analyzer.py --json
|
||||
```
|
||||
|
||||
## Analysis Results
|
||||
|
||||
### Current Status (Example)
|
||||
```
|
||||
timmy-config PR Backlog Analysis
|
||||
Total open PRs: 9
|
||||
Stale PRs: 2
|
||||
Zombie PRs: 1
|
||||
Needs reviewer: 3
|
||||
Reviewed: 2
|
||||
Pending: 1
|
||||
```
|
||||
|
||||
### Age Distribution
|
||||
- **0-7 days:** 3 PRs
|
||||
- **8-30 days:** 4 PRs
|
||||
- **31-90 days:** 1 PR
|
||||
- **90+ days:** 1 PR
|
||||
|
||||
### Author Distribution
|
||||
- **Timmy:** 5 PRs
|
||||
- **Rockachopa:** 2 PRs
|
||||
- **Gemini:** 1 PR
|
||||
- **Allegro:** 1 PR
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Close stale PRs** (>30 days old, >7 days since update)
|
||||
2. **Close zombie PRs** (no changes)
|
||||
3. **Assign reviewers** to PRs without reviewers
|
||||
4. **Merge reviewed PRs** that have been approved
|
||||
|
||||
## Action Plan
|
||||
|
||||
### Priority 1: Close Stale and Zombie PRs
|
||||
These PRs are not adding value:
|
||||
- [ ] Close PR #123: Stale PR (30 days old)
|
||||
- [ ] Close PR #456: Zombie PR (no changes)
|
||||
|
||||
### Priority 2: Assign Reviewers
|
||||
These PRs need reviewers:
|
||||
- [ ] Assign reviewer to PR #789
|
||||
- [ ] Assign reviewer to PR #101
|
||||
|
||||
### Priority 3: Merge Reviewed PRs
|
||||
These PRs have been reviewed:
|
||||
- [ ] Merge PR #111
|
||||
- [ ] Merge PR #222
|
||||
|
||||
### Priority 4: Review Pending PRs
|
||||
These PRs need review:
|
||||
- [ ] Review PR #333
|
||||
|
||||
## Integration
|
||||
|
||||
### With CI/CD
|
||||
```yaml
|
||||
# In CI workflow
|
||||
- name: Check PR backlog
|
||||
run: python bin/pr_backlog_analyzer.py --analyze
|
||||
```
|
||||
|
||||
### With Gitea Actions
|
||||
```yaml
|
||||
# In Gitea workflow
|
||||
- name: Generate backlog report
|
||||
run: python bin/pr_backlog_analyzer.py --report > backlog-report.md
|
||||
```
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **Issue #1470:** This implementation
|
||||
- **Issue #1127:** Perplexity Evening Pass triage (identified backlog)
|
||||
- **Issue #1459:** timmy-home backlog management
|
||||
|
||||
## Files
|
||||
|
||||
- `bin/pr_backlog_analyzer.py` - PR backlog analyzer tool
|
||||
- `docs/pr-backlog-analyzer.md` - This documentation
|
||||
|
||||
## Conclusion
|
||||
|
||||
This tool provides comprehensive analysis of the timmy-config PR backlog:
|
||||
1. **Analysis** - Understand backlog composition
|
||||
2. **Reporting** - Generate detailed reports
|
||||
3. **Action Plans** - Create prioritized action plans
|
||||
4. **Recommendations** - Get actionable recommendations
|
||||
|
||||
**Use this tool regularly to keep the PR backlog manageable.**
|
||||
|
||||
## License
|
||||
|
||||
Part of the Timmy Foundation project.
|
||||
Reference in New Issue
Block a user