#!/usr/bin/env python3
"""
GitHub Issue Triage Tool
Fetches, categorizes, and groups GitHub issues for efficient triage.
Optimized for large datasets with parallel processing support.
IMPORTANT: Never provide time, date, or effort estimates in output.
AI execution speed varies greatly from human timelines.
Focus on what needs to be done, not how long it takes.
"""
import argparse
import json
import os
import re
import subprocess
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional, Set, Tuple
from enum import Enum
from difflib import SequenceMatcher
import re
class Category(Enum):
"""Issue categories"""
BUG = "bug"
FEATURE = "feature"
ENHANCEMENT = "enhancement"
DOCUMENTATION = "documentation"
PERFORMANCE = "performance"
SECURITY = "security"
QUESTION = "question"
REFACTOR = "refactor"
TECH_DEBT = "tech-debt"
OTHER = "other"
class Priority(Enum):
"""Priority levels"""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
INFORMATIONAL = "informational"
class TriagingStatus(Enum):
"""Triage status"""
NEEDS_TRIAGE = "needs-triage"
READY_FOR_DEV = "ready-for-dev"
BLOCKED = "blocked"
STALE = "stale"
DUPLICATE = "duplicate"
INVALID = "invalid"
IN_PROGRESS = "in-progress"
COMPLETED = "completed"
@dataclass
class Issue:
"""Represents a GitHub issue with triage metadata"""
number: int
title: str
state: str
author: str
created_at: datetime
updated_at: Optional[datetime]
labels: List[str]
body: Optional[str]
comments: int
category: Category = field(default=Category.OTHER)
priority: Priority = field(default=Priority.MEDIUM)
triage_status: TriagingStatus = field(default=TriagingStatus.NEEDS_TRIAGE)
@property
def age_days(self) -> int:
"""Age of issue in days"""
return (datetime.now(timezone.utc) - self.created_at).days
@property
def days_since_update(self) -> Optional[int]:
"""Days since last update"""
if not self.updated_at:
return None
return (datetime.now(timezone.utc) - self.updated_at).days
@property
def url(self) -> str:
"""GitHub URL for the issue"""
# Get from parent triage object
return f"https://github.com/{self._repo_url}/issues/{self.number}"
class IssueCategorizer:
"""Categorizes issues based on content and metadata"""
# Keywords for categorization
CATEGORY_KEYWORDS = {
Category.BUG: ['bug', 'fix', 'crash', 'error', 'broken', 'fails', 'exception', 'segfault', 'leak'],
Category.FEATURE: ['feature', 'add ', 'implement', 'support for', 'new ', 'request', 'wish'],
Category.ENHANCEMENT: ['enhance', 'improve', 'optimize', 'better', 'enhancement'],
Category.DOCUMENTATION: ['doc', 'readme', 'tutorial', 'guide', 'documentation', 'example', 'comment'],
Category.PERFORMANCE: ['slow', 'performance', 'latency', 'speed', 'fast', 'optimize', 'memory'],
Category.SECURITY: ['security', 'vulnerability', 'exploit', 'xss', 'injection', 'csrf', 'auth'],
Category.QUESTION: ['question', 'how to', 'help', 'confusion', 'unclear', 'clarify'],
Category.REFACTOR: ['refactor', 'clean up', 'reorganize', 'restructure', 'simplify'],
Category.TECH_DEBT: ['tech debt', 'technical debt', 'legacy', 'deprecated', 'cleanup'],
}
# Priority indicators from labels
PRIORITY_LABELS = {
Priority.CRITICAL: ['critical', 'blocker', 'urgent'],
Priority.HIGH: ['high', 'important', 'priority'],
Priority.MEDIUM: ['medium'],
Priority.LOW: ['low', 'minor', 'trivial'],
}
def categorize(self, issue: Issue) -> Category:
"""Determine category based on title, body, and labels"""
text = f"{issue.title} {issue.body or ''}".lower()
# Check labels first
for label in issue.labels:
label_lower = label.lower()
if any(cat_str in label_lower for cat_str in ['bug', 'defect']):
return Category.BUG
if any(cat_str in label_lower for cat_str in ['feature', 'enhancement']):
return Category.FEATURE
if 'doc' in label_lower:
return Category.DOCUMENTATION
if 'perf' in label_lower:
return Category.PERFORMANCE
if 'security' in label_lower:
return Category.SECURITY
# Check keywords
for category, keywords in self.CATEGORY_KEYWORDS.items():
if any(keyword in text for keyword in keywords):
return category
return Category.OTHER
def determine_priority(self, issue: Issue) -> Priority:
"""Determine priority based on labels and metadata"""
# Check labels
for label in issue.labels:
label_lower = label.lower()
for priority, keywords in self.PRIORITY_LABELS.items():
if any(keyword in label_lower for keyword in keywords):
return priority
# Infer from metadata
if issue.age_days > 90 and issue.state == 'open':
# Old open issues are lower priority
return Priority.LOW
if issue.comments > 10:
# Highly discussed issues are important
return Priority.HIGH
return Priority.MEDIUM
def determine_triage_status(self, issue: Issue) -> TriagingStatus:
"""Determine triage status"""
# Check labels
for label in issue.labels:
label_lower = label.lower()
if 'duplicate' in label_lower:
return TriagingStatus.DUPLICATE
if any(x in label_lower for x in ['invalid', 'wontfix', 'wont-fix']):
return TriagingStatus.INVALID
if 'blocked' in label_lower or 'blocking' in label_lower:
return TriagingStatus.BLOCKED
if any(x in label_lower for x in ['in-progress', 'in progress', 'working']):
return TriagingStatus.IN_PROGRESS
# Check staleness
if issue.state.upper() == 'OPEN':
if issue.days_since_update and issue.days_since_update > 30:
return TriagingStatus.STALE
if not any(label.lower() in ['accepted', 'approved', 'ready'] for label in issue.labels):
return TriagingStatus.NEEDS_TRIAGE
return TriagingStatus.READY_FOR_DEV
return TriagingStatus.COMPLETED
class IssueTriage:
"""Main triage coordinator"""
# Module repository mapping
MODULE_REPOS = {
'builder': {
'repo': 'bmad-code-org/bmad-builder',
'names': ['bmb', 'builder', 'bmad-builder', 'agent builder', 'agent-builder'],
'url': 'https://github.com/bmad-code-org/bmad-builder'
},
'tea': {
'repo': 'bmad-code-org/bmad-method-test-architecture-enterprise',
'names': ['tea', 'test architect', 'test-architect', 'test architecture'],
'url': 'https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise'
},
'bmgd': {
'repo': 'bmad-code-org/bmad-module-game-dev-studio',
'names': ['bmgd', 'game dev', 'game-dev', 'gamedev', 'game dev studio', 'game-dev-studio'],
'url': 'https://github.com/bmad-code-org/bmad-module-game-dev-studio'
},
'cis': {
'repo': 'bmad-code-org/bmad-module-creative-intelligence-suite',
'names': ['cis', 'creative intelligence', 'creative-intelligence', 'creative intelligence suite'],
'url': 'https://github.com/bmad-code-org/bmad-module-creative-intelligence-suite'
},
}
def __init__(self, repo: Optional[str] = None, state: str = 'open'):
self.repo = repo or self._detect_repo()
self.state = state
self.categorizer = IssueCategorizer()
self.issues: List[Issue] = []
self._repo_url = self.repo # Store for issue URL generation
def _detect_repo(self) -> str:
"""Detect repository from git remote"""
try:
result = subprocess.run(
['git', 'remote', 'get-url', 'origin'],
capture_output=True,
text=True,
check=True
)
url = result.stdout.strip()
# Convert git@github.com:user/repo.git to user/repo
if url.startswith('git@github.com:'):
return url[15:-4]
if url.startswith('https://github.com/'):
return url[19:-4]
except subprocess.CalledProcessError:
pass
return 'unknown/repo'
def fetch_issues(self) -> List[Issue]:
"""Fetch issues using gh CLI"""
print(f"Fetching issues from {self.repo}...")
cmd = [
'gh', 'issue', 'list',
'--repo', self.repo,
'--state', self.state,
'--limit', '1000', # Fetch up to 1000 issues (default is 30)
'--json', 'number,title,state,author,createdAt,updatedAt,labels,body,comments'
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
self.issues = []
for item in data:
labels = [label['name'] for label in item.get('labels', [])]
issue = Issue(
number=item['number'],
title=item['title'],
state=item['state'],
author=item['author']['login'],
created_at=datetime.fromisoformat(item['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(item['updatedAt'].replace('Z', '+00:00')) if item.get('updatedAt') else None,
labels=labels,
body=item.get('body'),
comments=len(item.get('comments', []))
)
issue._repo_url = self._repo_url
self.issues.append(issue)
print(f"Fetched {len(self.issues)} issues")
return self.issues
def analyze_issues(self, focus_filter: Optional[str] = None) -> List[Issue]:
"""Analyze and categorize all issues"""
print("Analyzing issues...")
for issue in self.issues:
issue.category = self.categorizer.categorize(issue)
issue.priority = self.categorizer.determine_priority(issue)
issue.triage_status = self.categorizer.determine_triage_status(issue)
# Apply focus filter if provided
if focus_filter:
keywords = focus_filter.lower().split()
self.issues = [
issue for issue in self.issues
if any(keyword in f"{issue.title} {issue.body or ''}".lower()
for keyword in keywords)
]
print(f"Filtered to {len(self.issues)} issues matching focus criteria")
return self.issues
def find_duplicates(self, threshold: float = 0.7) -> List[Tuple[Issue, Issue, float]]:
"""Find potential duplicate issues based on title similarity"""
print("Detecting potential duplicates...")
duplicates = []
open_issues = [i for i in self.issues if i.state.upper() == 'OPEN']
for i, issue1 in enumerate(open_issues):
for issue2 in open_issues[i+1:]:
# Skip if already marked as duplicate
if 'duplicate' in [l.lower() for l in issue1.labels + issue2.labels]:
continue
# Calculate title similarity
similarity = SequenceMatcher(None, issue1.title.lower(), issue2.title.lower()).ratio()
if similarity >= threshold:
duplicates.append((issue1, issue2, similarity))
# Sort by similarity (highest first)
duplicates.sort(key=lambda x: x[2], reverse=True)
return duplicates
def find_outdated_issues(self, before_date: datetime = None) -> Dict[str, List[Issue]]:
"""Find issues that are likely outdated"""
print("Identifying outdated issues...")
if before_date is None:
# Default to December 1, 2025
before_date = datetime(2025, 12, 1, tzinfo=timezone.utc)
outdated = {
'old_issues': [],
'v4_issues': [],
'ancient_stale': []
}
open_issues = [i for i in self.issues if i.state.upper() == 'OPEN']
for issue in open_issues:
# Issues created before cutoff date
if issue.created_at < before_date:
outdated['old_issues'].append(issue)
# Issues mentioning v4
text = f"{issue.title} {issue.body or ''}".lower()
if 'v4' in text or 'version 4' in text or 'v 4' in text:
outdated['v4_issues'].append(issue)
# Issues very old and stale (>90 days since update)
if issue.age_days > 90 and (issue.days_since_update or 0) > 90:
outdated['ancient_stale'].append(issue)
return outdated
def generate_bulk_commands(self, issues: List[Issue], label: str) -> List[str]:
"""Generate gh CLI commands for bulk operations"""
commands = []
for issue in issues:
cmd = f"gh issue edit {issue.number} --repo {self.repo} --add-label '{label}'"
commands.append(cmd)
return commands
def generate_close_commands(self, issues: List[Issue], reason: str) -> List[str]:
"""Generate gh CLI commands to close issues with comment"""
commands = []
for issue in issues:
comment = reason.replace("'", "'\\''") # Escape single quotes
cmd = (f"gh issue close {issue.number} --repo {self.repo} "
f"--comment '{comment}'")
commands.append(cmd)
return commands
def find_cross_repo_issues(self) -> Dict[str, List[Tuple[Issue, str]]]:
"""Find issues that belong in other module repositories"""
print("Detecting cross-repo issues...")
cross_repo = defaultdict(list)
open_issues = [i for i in self.issues if i.state.upper() == 'OPEN']
for issue in open_issues:
text = f"{issue.title} {issue.body or ''}".lower()
for module_key, module_info in self.MODULE_REPOS.items():
# Check if issue mentions this module
for name in module_info['names']:
# Use word boundaries to avoid false positives
pattern = r'\b' + re.escape(name) + r'\b'
if re.search(pattern, text):
cross_repo[module_key].append((issue, name))
break # Only add once per issue
return cross_repo
def generate_actionable_recommendations(self) -> str:
"""Generate actionable recommendations with specific commands"""
lines = []
open_issues = [i for i in self.issues if i.state.upper() == 'OPEN']
lines.append("## 🎯 Actionable Recommendations\n")
# Cross-repo issues (show first!)
cross_repo_issues = self.find_cross_repo_issues()
if cross_repo_issues:
total_cross = sum(len(issues) for issues in cross_repo_issues.values())
lines.append(f"### Issues in Wrong Repository ({total_cross} issues)\n")
lines.append("**High Priority.** These issues should be closed here and opened in the correct module repository:\n")
for module_key, issues in cross_repo_issues.items():
if issues:
module_info = self.MODULE_REPOS[module_key]
lines.append(f"#### {module_info['repo'].replace('bmad-code-org/', '').title()} ({len(issues)} issues)")
lines.append(f"**Correct repo:** [{module_info['repo']}]({module_info['url']}/issues/new)\n")
lines.append(f"**Close these and report in the correct repo:**")
lines.append(f"```bash")
for issue, matched_name in issues[:10]: # Show first 10
comment = (f"This issue relates to {matched_name} which is maintained in a separate repository. "
f"Please report this issue at {module_info['url']}/issues/new")
lines.append(f"gh issue close {issue.number} --repo {self.repo} --comment '{comment}'")
if len(issues) > 10:
lines.append(f"# ... and {len(issues) - 10} more")
lines.append(f"```\n")
# Find duplicates
duplicates = self.find_duplicates()
if duplicates:
lines.append(f"### Potential Duplicates ({len(duplicates)} pairs)\n")
lines.append("**Manual review required.** Close the older issue as a duplicate of the newer one:\n")
for issue1, issue2, similarity in duplicates[:20]: # Top 20
older = issue1 if issue1.created_at < issue2.created_at else issue2
newer = issue2 if issue1.created_at < issue2.created_at else issue1
lines.append(f"#### {older.title}")
lines.append(f"- **Older:** #{older.number} ({older.age_days} days old)")
lines.append(f"- **Newer:** #{newer.number} ({newer.age_days} days old)")
lines.append(f"- **Similarity:** {similarity:.1%}")
lines.append(f"- **Command:** `gh issue close {older.number} --repo {self.repo} --comment 'Duplicate of #{newer.number}' --duplicate-of {newer.number}`")
lines.append("")
# Find outdated issues
outdated = self.find_outdated_issues()
total_outdated = len(outdated['old_issues']) + len(outdated['v4_issues']) + len(outdated['ancient_stale'])
if total_outdated > 0:
lines.append(f"### Outdated Issues ({total_outdated} total)\n")
# Pre-Dec 2025 issues
if outdated['old_issues']:
cutoff_date = datetime(2025, 12, 1, tzinfo=timezone.utc).strftime('%B %Y')
lines.append(f"#### Issues from before {cutoff_date} ({len(outdated['old_issues'])})")
lines.append(f"These issues are quite old and may no longer be relevant. Consider reviewing and closing outdated ones.\n")
lines.append("**To add label for review:**")
lines.append(f"```bash")
for issue in outdated['old_issues'][:10]: # Show first 10
lines.append(f"gh issue edit {issue.number} --repo {self.repo} --add-label 'outdated,needs-review'")
if len(outdated['old_issues']) > 10:
lines.append(f"# ... and {len(outdated['old_issues']) - 10} more")
lines.append(f"```\n")
# v4-related issues
if outdated['v4_issues']:
lines.append(f"#### v4-Related Issues ({len(outdated['v4_issues'])})")
lines.append(f"BMad Method v4 is deprecated. These issues likely no longer apply to v6.\n")
lines.append("**Bulk close with comment:**")
lines.append(f"```bash")
for issue in outdated['v4_issues'][:10]:
lines.append(f"gh issue close {issue.number} --repo {self.repo} --comment 'Closing as this relates to BMad Method v4 which is deprecated. Please open a new issue if this still applies to v6.'")
if len(outdated['v4_issues']) > 10:
lines.append(f"# ... and {len(outdated['v4_issues']) - 10} more")
lines.append(f"```\n")
# Ancient stale issues
if outdated['ancient_stale']:
lines.append(f"#### Ancient Stale Issues ({len(outdated['ancient_stale'])})")
lines.append(f"Issues that are both very old (>90 days) and haven't been updated in >90 days.\n")
lines.append("**Close as stale:**")
lines.append(f"```bash")
for issue in outdated['ancient_stale'][:10]:
lines.append(f"gh issue close {issue.number} --repo {self.repo} --comment 'Closing due to inactivity. Please reopen if this is still relevant.'")
if len(outdated['ancient_stale']) > 10:
lines.append(f"# ... and {len(outdated['ancient_stale']) - 10} more")
lines.append(f"```\n")
# Bulk tagging suggestions
lines.append("### Bulk Tagging Suggestions\n")
lines.append("Add appropriate labels to untagged issues:\n")
# Find issues without category labels
untagged = [i for i in open_issues if not any(
l.lower() in ['bug', 'feature', 'enhancement', 'documentation', 'performance', 'question', 'refactor', 'tech-debt']
for l in i.labels
)]
if untagged:
lines.append(f"**Issues without category labels ({len(untagged)}):**\n")
by_category = defaultdict(list)
for issue in untagged:
by_category[issue.category.value].append(issue)
for category, issues in sorted(by_category.items(), key=lambda x: len(x[1]), reverse=True)[:5]:
lines.append(f"##### Label as `{category}` ({len(issues)} issues)")
lines.append(f"```bash")
for issue in issues[:5]:
lines.append(f"gh issue edit {issue.number} --repo {self.repo} --add-label '{category}'")
if len(issues) > 5:
lines.append(f"# ... and {len(issues) - 5} more")
lines.append(f"```\n")
# Priority labeling
no_priority = [i for i in open_issues if not any(
l.lower() in ['critical', 'high', 'medium', 'low', 'priority']
for l in i.labels
)]
if no_priority:
# Group by priority
by_priority = defaultdict(list)
for issue in no_priority:
by_priority[issue.priority.value].append(issue)
lines.append("**Add priority labels:**\n")
for priority_level in ['critical', 'high', 'medium', 'low']:
if priority_level in by_priority:
count = len(by_priority[priority_level])
lines.append(f"##### Label as `{priority_level}` priority ({count} issues)")
lines.append(f"```bash")
for issue in by_priority[priority_level][:5]:
lines.append(f"gh issue edit {issue.number} --repo {self.repo} --add-label '{priority_level}'")
if count > 5:
lines.append(f"# ... and {count - 5} more")
lines.append(f"```\n")
return "\n".join(lines)
def generate_report(self) -> str:
"""Generate markdown triage report"""
lines = []
# Summary
lines.append("# GitHub Issue Triage Report\n")
lines.append(f"**Repository:** {self.repo}\n")
lines.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
# Count stats
total = len(self.issues)
open_issues = [i for i in self.issues if i.state.upper() == 'OPEN']
closed_issues = [i for i in self.issues if i.state.upper() == 'CLOSED']
lines.append("## Summary\n")
lines.append(f"- **Total Issues:** {total}")
lines.append(f"- **Open:** {len(open_issues)} | **Closed:** {len(closed_issues)}")
# Category breakdown
category_counts = defaultdict(int)
for issue in self.issues:
category_counts[issue.category] += 1
top_categories = sorted(category_counts.items(), key=lambda x: x[1], reverse=True)[:5]
lines.append(f"- **Top Categories:** {', '.join(f'{cat.value} ({count})' for cat, count in top_categories)}")
lines.append("")
# Priority action items
lines.append("## Priority Action Items\n")
# Critical/High priority open issues
critical_issues = [i for i in open_issues if i.priority in [Priority.CRITICAL, Priority.HIGH]]
if critical_issues:
lines.append(f"### {'🚨 ' if critical_issues else ''}Critical & High Priority ({len(critical_issues)})\n")
for issue in sorted(critical_issues, key=lambda x: x.age_days, reverse=True):
lines.append(f"- [#{issue.number}]({issue.url}) {issue.title}")
lines.append(f" - {issue.category.value.upper()} | {issue.priority.value.upper()} | Age: {issue.age_days} days")
if issue.labels:
lines.append(f" - Labels: {', '.join(issue.labels)}")
lines.append("")
# Stale issues needing review
stale_issues = [i for i in open_issues if i.triage_status == TriagingStatus.STALE]
if stale_issues:
lines.append(f"### Stale Issues - Needs Review ({len(stale_issues)})\n")
for issue in sorted(stale_issues, key=lambda x: x.days_since_update or 0, reverse=True)[:15]:
days_stale = issue.days_since_update or 0
lines.append(f"- [#{issue.number}]({issue.url}) {issue.title}")
lines.append(f" - Last updated {days_stale} days ago | {issue.category.value}")
lines.append("")
# Categories
lines.append("## Categories\n")
for category in Category:
category_issues = [i for i in self.issues if i.category == category]
if not category_issues:
continue
open_in_cat = [i for i in category_issues if i.state.upper() == 'OPEN']
closed_in_cat = [i for i in category_issues if i.state.upper() == 'CLOSED']
lines.append(f"### {category.value.title()} ({len(open_in_cat)} open, {len(closed_in_cat)} closed)\n")
# Sort open by priority
priority_order = {Priority.CRITICAL: 0, Priority.HIGH: 1, Priority.MEDIUM: 2, Priority.LOW: 3, Priority.INFORMATIONAL: 4}
open_in_cat_sorted = sorted(open_in_cat, key=lambda x: priority_order.get(x.priority, 5))
for issue in open_in_cat_sorted[:20]: # Limit to 20 per category
status_icon = {
TriagingStatus.NEEDS_TRIAGE: '🔍',
TriagingStatus.READY_FOR_DEV: '✅',
TriagingStatus.BLOCKED: '🚫',
TriagingStatus.STALE: '💤',
TriagingStatus.IN_PROGRESS: '🔧',
}.get(issue.triage_status, '')
lines.append(f"{status_icon} [#{issue.number}]({issue.url}) {issue.title}")
lines.append(f" Details
")
lines.append(f" ")
lines.append(f" - **Priority:** {issue.priority.value}")
lines.append(f" - **Status:** {issue.triage_status.value}")
lines.append(f" - **Age:** {issue.age_days} days")
lines.append(f" - **Author:** {issue.author}")
if issue.labels:
lines.append(f" - **Labels:** {', '.join(issue.labels)}")
lines.append(f" ")
lines.append("")
if len(open_in_cat) > 20:
lines.append(f"*... and {len(open_in_cat) - 20} more*\n")
# Actionable recommendations
lines.append(self.generate_actionable_recommendations())
# Cleanup candidates
lines.append("## Cleanup Candidates\n")
duplicates = [i for i in self.issues if i.triage_status == TriagingStatus.DUPLICATE]
if duplicates:
lines.append(f"### Duplicates ({len(duplicates)})\n")
for issue in duplicates:
lines.append(f"- [#{issue.number}]({issue.url}) {issue.title}")
invalid = [i for i in self.issues if i.triage_status == TriagingStatus.INVALID]
if invalid:
lines.append(f"\n### Invalid/Wontfix ({len(invalid)})\n")
for issue in invalid:
lines.append(f"- [#{issue.number}]({issue.url}) {issue.title}")
lines.append("\n---\n")
lines.append("*Report generated by BMad Issue Triage Tool*")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description='Triage and categorize GitHub issues',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Triage open issues in current repo
%(prog)s --state all # All issues (including closed)
%(prog)s --focus "installer" # Filter for installer-related issues
%(prog)s --repo user/repo --state closed
"""
)
parser.add_argument('--repo', help='Repository (default: detect from git)')
parser.add_argument('--state', choices=['all', 'open', 'closed'], default='open',
help='Filter by state (default: open)')
parser.add_argument('--focus', help='Focus context to filter issues')
parser.add_argument('--output', '-o', help='Output file (default: _bmad-output/triage-reports/triage-.md)')
parser.add_argument('--json', action='store_true', help='Output as JSON')
args = parser.parse_args()
# Set default output to _bmad-output/triage-reports if not specified
if not args.output and not args.json:
os.makedirs('_bmad-output/triage-reports', exist_ok=True)
output_date = datetime.now().strftime('%Y-%m-%d')
args.output = f'_bmad-output/triage-reports/triage-{output_date}.md'
triage = IssueTriage(repo=args.repo, state=args.state)
triage.fetch_issues()
triage.analyze_issues(focus_filter=args.focus)
if args.json:
# Output as JSON for further processing
data = [
{
'number': i.number,
'title': i.title,
'state': i.state,
'category': i.category.value,
'priority': i.priority.value,
'triage_status': i.triage_status.value,
'age_days': i.age_days,
'url': i.url,
'labels': i.labels,
'author': i.author
}
for i in triage.issues
]
output = json.dumps(data, indent=2)
else:
output = triage.generate_report()
if args.output:
with open(args.output, 'w') as f:
f.write(output)
# Get relative path for nicer output
rel_path = args.output.replace('./', '')
print(f"✅ Report saved to: {rel_path}")
else:
print(output)
if __name__ == '__main__':
main()