Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bce98a761 |
142
docs/weekly-triage-cadence.md
Normal file
142
docs/weekly-triage-cadence.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Weekly Backlog Triage Cadence
|
||||
|
||||
**Issue:** #685 - [OPS] timmy-home backlog reduced from 220 to 50 — triage cadence needed
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the weekly triage cadence for maintaining the timmy-home backlog.
|
||||
|
||||
## Problem
|
||||
|
||||
timmy-home had 220 open issues (highest in org). Through batch-pipeline codebase genome issues, the backlog was reduced to 50. To maintain this visibility, a weekly triage cadence is needed.
|
||||
|
||||
## Current Status
|
||||
|
||||
- **Total open issues:** 50 (reduced from 220)
|
||||
- **Unassigned issues:** 21
|
||||
- **Issues with no labels:** 21
|
||||
- **Batch-pipeline issues:** 19 (triaged with comments)
|
||||
|
||||
## Solution
|
||||
|
||||
### Weekly Triage Script (`scripts/backlog_triage.py`)
|
||||
Script to analyze and report on the timmy-home backlog.
|
||||
|
||||
**Features:**
|
||||
- Analyze open issues
|
||||
- Identify stale issues
|
||||
- Generate reports
|
||||
- Create cron entries
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Analyze backlog
|
||||
python scripts/backlog_triage.py --analyze
|
||||
|
||||
# Generate report
|
||||
python scripts/backlog_triage.py --report
|
||||
|
||||
# JSON output
|
||||
python scripts/backlog_triage.py --json
|
||||
|
||||
# Generate cron entry
|
||||
python scripts/backlog_triage.py --cron
|
||||
```
|
||||
|
||||
### Cron Entry
|
||||
|
||||
Add to crontab for weekly execution:
|
||||
|
||||
```cron
|
||||
# Weekly timmy-home backlog triage
|
||||
# Run every Monday at 9:00 AM
|
||||
0 9 * * 1 cd /path/to/timmy-home && python3 scripts/backlog_triage.py --report > /var/log/timmy-home-triage-$(date +\%Y\%m\%d).log 2>&1
|
||||
```
|
||||
|
||||
## Triage Process
|
||||
|
||||
### 1. Run Weekly Analysis
|
||||
```bash
|
||||
# Generate report
|
||||
python scripts/backlog_triage.py --report > triage-report-$(date +%Y%m%d).md
|
||||
```
|
||||
|
||||
### 2. Review Stale Issues
|
||||
- Issues >30 days old with no labels/assignee
|
||||
- Close or re-prioritize as needed
|
||||
|
||||
### 3. Assign Labels and Owners
|
||||
- Unassigned issues need owners
|
||||
- Unlabeled issues need labels
|
||||
|
||||
### 4. Update Documentation
|
||||
- Document triage cadence in CONTRIBUTING.md
|
||||
- Add to morning report if applicable
|
||||
|
||||
## Metrics to Track
|
||||
|
||||
### Weekly Metrics
|
||||
- Total open issues
|
||||
- Unassigned issues
|
||||
- Unlabeled issues
|
||||
- Stale issues (>30 days)
|
||||
- Batch-pipeline issues
|
||||
|
||||
### Monthly Metrics
|
||||
- Issue creation rate
|
||||
- Issue closure rate
|
||||
- Average time to close
|
||||
- Label usage trends
|
||||
|
||||
## Integration
|
||||
|
||||
### With Morning Report
|
||||
Add to morning report:
|
||||
```bash
|
||||
# In morning report script
|
||||
python scripts/backlog_triage.py --report
|
||||
```
|
||||
|
||||
### With Cron
|
||||
Add to system crontab:
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
|
||||
# Add weekly triage
|
||||
0 9 * * 1 cd /path/to/timmy-home && python3 scripts/backlog_triage.py --report > /var/log/timmy-home-triage-$(date +\%Y\%m\%d).log 2>&1
|
||||
```
|
||||
|
||||
### With CI/CD
|
||||
Add to CI workflow:
|
||||
```yaml
|
||||
- name: Weekly backlog triage
|
||||
run: |
|
||||
python scripts/backlog_triage.py --report > triage-report.md
|
||||
# Upload report as artifact or send notification
|
||||
```
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **Issue #685:** This implementation
|
||||
- **Issue #1459:** timmy-home backlog management
|
||||
- **Issue #1127:** Perplexity Evening Pass triage (identified backlog)
|
||||
|
||||
## Files
|
||||
|
||||
- `scripts/backlog_triage.py` - Weekly triage script
|
||||
- `docs/weekly-triage-cadence.md` - This documentation
|
||||
|
||||
## Conclusion
|
||||
|
||||
This implementation provides a weekly triage cadence to maintain the timmy-home backlog:
|
||||
1. **Weekly analysis** of open issues
|
||||
2. **Reporting** on stale and unassigned issues
|
||||
3. **Cron integration** for automated execution
|
||||
4. **Metrics tracking** for ongoing visibility
|
||||
|
||||
**Use this script weekly to keep the backlog manageable.**
|
||||
|
||||
## License
|
||||
|
||||
Part of the Timmy Foundation project.
|
||||
@@ -1,153 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
backlog_triage.py — Weekly backlog health check for timmy-home.
|
||||
Weekly Backlog Triage for timmy-home
|
||||
Issue #685: [OPS] timmy-home backlog reduced from 220 to 50 — triage cadence needed
|
||||
|
||||
Queries Gitea API for open issues and reports:
|
||||
- Unassigned issues
|
||||
- Issues with no labels
|
||||
- Batch-pipeline issues (triaged with comments)
|
||||
|
||||
Usage:
|
||||
python scripts/backlog_triage.py [--token TOKEN] [--repo OWNER/REPO]
|
||||
|
||||
Exit codes:
|
||||
0 = backlog healthy (no action needed)
|
||||
1 = issues found requiring attention
|
||||
Run this script weekly to maintain backlog visibility.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List
|
||||
|
||||
GITEA_BASE = os.environ.get("GITEA_BASE_URL", "https://forge.alexanderwhitestone.com/api/v1")
|
||||
# Configuration
|
||||
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
TOKEN_PATH = os.path.expanduser("~/.config/gitea/token")
|
||||
ORG = "Timmy_Foundation"
|
||||
REPO = "timmy-home"
|
||||
|
||||
|
||||
def fetch_issues(owner: str, repo: str, token: str, state: str = "open") -> list:
|
||||
"""Fetch all open issues from Gitea."""
|
||||
issues = []
|
||||
page = 1
|
||||
per_page = 50
|
||||
|
||||
while True:
|
||||
url = f"{GITEA_BASE}/repos/{owner}/{repo}/issues?state={state}&page={page}&per_page={per_page}&type=issues"
|
||||
req = Request(url)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
|
||||
class BacklogTriage:
|
||||
"""Weekly backlog triage for timmy-home."""
|
||||
|
||||
def __init__(self):
|
||||
self.token = self._load_token()
|
||||
|
||||
def _load_token(self) -> str:
|
||||
"""Load Gitea API token."""
|
||||
try:
|
||||
with urlopen(req) as resp:
|
||||
batch = json.loads(resp.read())
|
||||
except URLError as e:
|
||||
print(f"ERROR: Failed to fetch issues: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
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_issues(self) -> List[Dict]:
|
||||
"""Get all open issues."""
|
||||
endpoint = f"/repos/{ORG}/{REPO}/issues?state=open&limit=200"
|
||||
issues = self._api_request(endpoint)
|
||||
return issues if isinstance(issues, list) else []
|
||||
|
||||
def analyze_backlog(self, issues: List[Dict]) -> Dict[str, Any]:
|
||||
"""Analyze the backlog."""
|
||||
analysis = {
|
||||
"total_open": len(issues),
|
||||
"unassigned": 0,
|
||||
"unlabeled": 0,
|
||||
"batch_pipeline": 0,
|
||||
"by_label": {},
|
||||
"by_assignee": {},
|
||||
"by_age": {
|
||||
"0-7_days": 0,
|
||||
"8-30_days": 0,
|
||||
"31-90_days": 0,
|
||||
"90+_days": 0
|
||||
},
|
||||
"stale_issues": [],
|
||||
"unassigned_unlabeled": []
|
||||
}
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=30)
|
||||
|
||||
for issue in issues:
|
||||
# Skip PRs
|
||||
if 'pull_request' in issue:
|
||||
continue
|
||||
|
||||
# Check assignment
|
||||
if not issue.get('assignee'):
|
||||
analysis["unassigned"] += 1
|
||||
|
||||
# Check labels
|
||||
labels = [l['name'] for l in issue.get('labels', [])]
|
||||
if not labels:
|
||||
analysis["unlabeled"] += 1
|
||||
else:
|
||||
for label in labels:
|
||||
analysis["by_label"][label] = analysis["by_label"].get(label, 0) + 1
|
||||
|
||||
# Check assignee
|
||||
assignee = issue.get('assignee')
|
||||
if assignee:
|
||||
assignee_name = assignee['login']
|
||||
analysis["by_assignee"][assignee_name] = analysis["by_assignee"].get(assignee_name, 0) + 1
|
||||
|
||||
# Check if batch-pipeline issue
|
||||
if 'batch-pipeline' in labels:
|
||||
analysis["batch_pipeline"] += 1
|
||||
|
||||
# Check age
|
||||
created_at = datetime.fromisoformat(issue['created_at'].replace('Z', '+00:00'))
|
||||
age_days = (datetime.now() - created_at).days
|
||||
|
||||
if age_days <= 7:
|
||||
analysis["by_age"]["0-7_days"] += 1
|
||||
elif age_days <= 30:
|
||||
analysis["by_age"]["8-30_days"] += 1
|
||||
elif age_days <= 90:
|
||||
analysis["by_age"]["31-90_days"] += 1
|
||||
else:
|
||||
analysis["by_age"]["90+_days"] += 1
|
||||
|
||||
# Check if stale (>30 days old and no labels/assignee)
|
||||
if age_days > 30 and not labels and not issue.get('assignee'):
|
||||
analysis["stale_issues"].append({
|
||||
"number": issue['number'],
|
||||
"title": issue['title'],
|
||||
"created": issue['created_at'],
|
||||
"age_days": age_days
|
||||
})
|
||||
|
||||
# Check if unassigned and unlabeled
|
||||
if not issue.get('assignee') and not labels:
|
||||
analysis["unassigned_unlabeled"].append({
|
||||
"number": issue['number'],
|
||||
"title": issue['title'],
|
||||
"created": issue['created_at']
|
||||
})
|
||||
|
||||
return analysis
|
||||
|
||||
def generate_report(self, analysis: Dict[str, Any]) -> str:
|
||||
"""Generate a triage report."""
|
||||
report = f"# timmy-home Weekly Backlog Triage\n\n"
|
||||
report += f"Generated: {datetime.now().isoformat()}\n\n"
|
||||
|
||||
report += "## Summary\n"
|
||||
report += f"- **Total open issues:** {analysis['total_open']}\n"
|
||||
report += f"- **Unassigned:** {analysis['unassigned']}\n"
|
||||
report += f"- **Unlabeled:** {analysis['unlabeled']}\n"
|
||||
report += f"- **Batch-pipeline issues:** {analysis['batch_pipeline']}\n"
|
||||
report += f"- **Stale issues (>30 days, no labels/assignee):** {len(analysis['stale_issues'])}\n"
|
||||
report += f"- **Unassigned + Unlabeled:** {len(analysis['unassigned_unlabeled'])}\n\n"
|
||||
|
||||
report += "## Age Distribution\n"
|
||||
for age_range, count in analysis['by_age'].items():
|
||||
report += f"- **{age_range}:** {count} issues\n"
|
||||
|
||||
report += "\n## Label Distribution\n"
|
||||
if analysis['by_label']:
|
||||
for label, count in sorted(analysis['by_label'].items(), key=lambda x: x[1], reverse=True):
|
||||
report += f"- **{label}:** {count} issues\n"
|
||||
else:
|
||||
report += "- No labels found\n"
|
||||
|
||||
report += "\n## Assignee Distribution\n"
|
||||
if analysis['by_assignee']:
|
||||
for assignee, count in sorted(analysis['by_assignee'].items(), key=lambda x: x[1], reverse=True):
|
||||
report += f"- **@{assignee}:** {count} issues\n"
|
||||
else:
|
||||
report += "- No assignees found\n"
|
||||
|
||||
if analysis['stale_issues']:
|
||||
report += "\n## Stale Issues (>30 days, no labels/assignee)\n"
|
||||
report += "These issues should be triaged or closed:\n"
|
||||
for issue in analysis['stale_issues'][:10]: # Show first 10
|
||||
report += f"- **#{issue['number']}**: {issue['title']}\n"
|
||||
report += f" - Age: {issue['age_days']} days\n"
|
||||
report += f" - Created: {issue['created']}\n"
|
||||
|
||||
if analysis['unassigned_unlabeled']:
|
||||
report += "\n## Unassigned + Unlabeled Issues\n"
|
||||
report += "These issues need labels and/or assignees:\n"
|
||||
for issue in analysis['unassigned_unlabeled'][:10]: # Show first 10
|
||||
report += f"- **#{issue['number']}**: {issue['title']}\n"
|
||||
report += f" - Created: {issue['created']}\n"
|
||||
|
||||
report += "\n## Recommendations\n"
|
||||
if analysis['unassigned'] > 0:
|
||||
report += f"1. **Assign owners to {analysis['unassigned']} issues** - Ensure accountability\n"
|
||||
if analysis['unlabeled'] > 0:
|
||||
report += f"2. **Add labels to {analysis['unlabeled']} issues** - Categorize for management\n"
|
||||
if len(analysis['stale_issues']) > 0:
|
||||
report += f"3. **Triage {len(analysis['stale_issues'])} stale issues** - Close or re-prioritize\n"
|
||||
if len(analysis['unassigned_unlabeled']) > 0:
|
||||
report += f"4. **Address {len(analysis['unassigned_unlabeled'])} unassigned/unlabeled issues** - Basic triage needed\n"
|
||||
|
||||
return report
|
||||
|
||||
def generate_cron_entry(self) -> str:
|
||||
"""Generate cron entry for weekly triage."""
|
||||
cron_entry = """# Weekly timmy-home backlog triage
|
||||
# Run every Monday at 9:00 AM
|
||||
0 9 * * 1 cd /path/to/timmy-home && python3 scripts/backlog_triage.py --report > /var/log/timmy-home-triage-$(date +\\%Y\\%m\\%d).log 2>&1
|
||||
|
||||
if not batch:
|
||||
break
|
||||
|
||||
issues.extend(batch)
|
||||
page += 1
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def categorize_issues(issues: list) -> dict:
|
||||
"""Categorize issues into triage buckets."""
|
||||
unassigned = []
|
||||
no_labels = []
|
||||
batch_pipeline = []
|
||||
|
||||
for issue in issues:
|
||||
# Skip pull requests (Gitea includes them in issues endpoint)
|
||||
if "pull_request" in issue:
|
||||
continue
|
||||
|
||||
number = issue["number"]
|
||||
title = issue["title"]
|
||||
assignee = issue.get("assignee")
|
||||
labels = issue.get("labels", [])
|
||||
|
||||
if not assignee:
|
||||
unassigned.append({"number": number, "title": title})
|
||||
|
||||
if not labels:
|
||||
no_labels.append({"number": number, "title": title})
|
||||
|
||||
if "batch-pipeline" in title.lower() or any(
|
||||
lbl.get("name", "").lower() == "batch-pipeline" for lbl in labels
|
||||
):
|
||||
batch_pipeline.append({"number": number, "title": title})
|
||||
|
||||
return {
|
||||
"unassigned": unassigned,
|
||||
"no_labels": no_labels,
|
||||
"batch_pipeline": batch_pipeline,
|
||||
}
|
||||
|
||||
|
||||
def print_report(owner: str, repo: str, categories: dict) -> int:
|
||||
"""Print triage report and return count of issues needing attention."""
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
print(f"# Backlog Triage Report — {owner}/{repo}")
|
||||
print(f"Generated: {now}\n")
|
||||
|
||||
total_attention = 0
|
||||
|
||||
# Unassigned
|
||||
print(f"## Unassigned Issues ({len(categories['unassigned'])})")
|
||||
if categories["unassigned"]:
|
||||
total_attention += len(categories["unassigned"])
|
||||
for item in categories["unassigned"]:
|
||||
print(f" - #{item['number']}: {item['title']}")
|
||||
else:
|
||||
print(" ✓ None")
|
||||
print()
|
||||
|
||||
# No labels
|
||||
print(f"## Issues with No Labels ({len(categories['no_labels'])})")
|
||||
if categories["no_labels"]:
|
||||
total_attention += len(categories["no_labels"])
|
||||
for item in categories["no_labels"]:
|
||||
print(f" - #{item['number']}: {item['title']}")
|
||||
else:
|
||||
print(" ✓ None")
|
||||
print()
|
||||
|
||||
# Batch-pipeline
|
||||
print(f"## Batch-Pipeline Issues ({len(categories['batch_pipeline'])})")
|
||||
if categories["batch_pipeline"]:
|
||||
for item in categories["batch_pipeline"]:
|
||||
print(f" - #{item['number']}: {item['title']}")
|
||||
else:
|
||||
print(" ✓ None")
|
||||
print()
|
||||
|
||||
print(f"---\nTotal issues requiring attention: {total_attention}")
|
||||
return total_attention
|
||||
# Or run directly:
|
||||
# python3 scripts/backlog_triage.py --report"""
|
||||
|
||||
return cron_entry
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Weekly backlog triage for timmy-home")
|
||||
parser.add_argument("--token", default=os.environ.get("GITEA_TOKEN", ""),
|
||||
help="Gitea API token (or set GITEA_TOKEN env)")
|
||||
parser.add_argument("--repo", default="Timmy_Foundation/timmy-home",
|
||||
help="Repository in OWNER/REPO format")
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Weekly Backlog Triage for timmy-home")
|
||||
parser.add_argument("--analyze", action="store_true", help="Analyze backlog")
|
||||
parser.add_argument("--report", action="store_true", help="Generate report")
|
||||
parser.add_argument("--cron", action="store_true", help="Generate cron entry")
|
||||
parser.add_argument("--json", action="store_true", help="Output JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.token:
|
||||
print("ERROR: No Gitea token provided. Set GITEA_TOKEN or use --token.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
owner, repo = args.repo.split("/", 1)
|
||||
|
||||
issues = fetch_issues(owner, repo, args.token)
|
||||
categories = categorize_issues(issues)
|
||||
needs_attention = print_report(owner, repo, categories)
|
||||
|
||||
sys.exit(1 if needs_attention > 0 else 0)
|
||||
|
||||
triage = BacklogTriage()
|
||||
|
||||
if args.analyze or args.report or args.json:
|
||||
issues = triage.get_open_issues()
|
||||
analysis = triage.analyze_backlog(issues)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(analysis, indent=2))
|
||||
elif args.report:
|
||||
report = triage.generate_report(analysis)
|
||||
print(report)
|
||||
else:
|
||||
# Default: show summary
|
||||
print(f"timmy-home Backlog Analysis:")
|
||||
print(f" Total open issues: {analysis['total_open']}")
|
||||
print(f" Unassigned: {analysis['unassigned']}")
|
||||
print(f" Unlabeled: {analysis['unlabeled']}")
|
||||
print(f" Batch-pipeline: {analysis['batch_pipeline']}")
|
||||
print(f" Stale issues: {len(analysis['stale_issues'])}")
|
||||
print(f" Unassigned + Unlabeled: {len(analysis['unassigned_unlabeled'])}")
|
||||
|
||||
elif args.cron:
|
||||
# Generate cron entry
|
||||
cron_entry = triage.generate_cron_entry()
|
||||
print(cron_entry)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
Reference in New Issue
Block a user