Compare commits
1 Commits
mimo/code/
...
fix/1470
| 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