#!/usr/bin/env python3 """ ProjectTracker - Summary Generator Reads state.json and generates human-readable summary.md """ import json import sys import os from pathlib import Path from typing import Dict, List, Tuple # Force UTF-8 encoding for Windows console (emoji support) os.environ['PYTHONIOENCODING'] = 'utf-8' if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8') # Constants SCRIPT_DIR = Path(__file__).parent PROJECT_TRACKER = SCRIPT_DIR.parent STATE_FILE = PROJECT_TRACKER / ".planning" / "state.json" CONFIG_FILE = SCRIPT_DIR / "config.json" SUMMARY_FILE = PROJECT_TRACKER / ".planning" / "summary.md" # Categories order for display CATEGORIES_ORDER = ["META", "CONSTANT", "WIP", "CONCEPT", "PAUSE", "DONE"] def load_json(path: Path) -> dict: """Load JSON file""" with open(path, 'r', encoding='utf-8') as f: return json.load(f) def get_category(project_file: str) -> str: """Extract category from project file path Example: projects/WIP/repo.md -> WIP """ if not project_file: return "UNCATEGORIZED" parts = project_file.split('/') if len(parts) >= 2 and parts[0] == "projects": return parts[1] return "UNCATEGORIZED" def format_repo_status(repo_name: str, data: Dict) -> str: """Format single repo status line with indicators""" indicators = [] # Priority order: conflict > uncommitted > new commits > behind if data.get("has_conflict"): indicators.append("❌ CONFLICT") if data.get("has_uncommitted_changes"): count = data.get("uncommitted_count", 0) indicators.append(f"📝 {count} uncommitted") if data.get("new_commits_since_last_scan", 0) > 0: count = data["new_commits_since_last_scan"] indicators.append(f"🆕 {count} new") if data.get("behind", 0) > 0: count = data["behind"] indicators.append(f"⬆️ {count} behind") status = " | ".join(indicators) if indicators else "✅" # Format output branch = data.get("branch", "unknown") msg = data.get("last_commit_msg", "No commits") date = data.get("last_commit_date", "unknown") return f"- **{repo_name}** ({branch}) - {status}\n Last: {msg} ({date})" def categorize_repos(repos: Dict, config: Dict) -> Dict[str, List[Tuple[str, Dict]]]: """Organize repos by category""" categorized = {cat: [] for cat in CATEGORIES_ORDER} categorized["UNCATEGORIZED"] = [] for repo_name, data in repos.items(): category = get_category(data.get("project_file", "")) if category in categorized: categorized[category].append((repo_name, data)) else: categorized["UNCATEGORIZED"].append((repo_name, data)) return categorized def generate_summary(): """Main function: generate summary.md from state.json""" # Load data state = load_json(STATE_FILE) config = load_json(CONFIG_FILE) repos = state["repos"] stats = state["stats"] last_scan = state["last_scan"] # Categorize categorized = categorize_repos(repos, config) # Start building markdown md = [] md.append("# ProjectTracker - Summary\n") md.append(f"**Last Scan:** {last_scan}\n") md.append("---\n") # Global Stats md.append("## 📊 Global Stats\n") md.append(f"- **Total Repos:** {stats['total_repos']}") md.append(f"- **Active (3w):** {stats['active_3w']}") md.append(f"- **Needs Attention:** {stats['needs_attention']}") md.append(f"- **Conflicts:** {stats['conflicts']}\n") # Needs Attention Section attention_repos = [ (name, data) for name, data in repos.items() if data.get("needs_attention") ] if attention_repos: md.append("## ⚠️ Needs Attention\n") # Sort by priority: conflicts > uncommitted > new commits > behind attention_repos.sort(key=lambda x: ( -int(x[1].get("has_conflict", False)), -int(x[1].get("has_uncommitted_changes", False)), -x[1].get("new_commits_since_last_scan", 0), -x[1].get("behind", 0) )) for repo_name, data in attention_repos: md.append(format_repo_status(repo_name, data)) md.append("") # By Category Section md.append("## 📂 By Category\n") for category in CATEGORIES_ORDER: repos_in_cat = categorized[category] if repos_in_cat: md.append(f"### {category} ({len(repos_in_cat)})\n") for repo_name, data in sorted(repos_in_cat): md.append(format_repo_status(repo_name, data)) md.append("") # Uncategorized (if any) if categorized["UNCATEGORIZED"]: count = len(categorized["UNCATEGORIZED"]) md.append(f"### UNCATEGORIZED ({count})\n") for repo_name, data in sorted(categorized["UNCATEGORIZED"]): md.append(format_repo_status(repo_name, data)) md.append("") # Recent Activity Section active_repos = [ (name, data) for name, data in repos.items() if data.get("commits_3w", 0) > 0 ] if active_repos: md.append("## 🔥 Recent Activity (3 weeks)\n") # Sort by commit count (descending) active_repos.sort(key=lambda x: -x[1]["commits_3w"]) for repo_name, data in active_repos: commits = data["commits_3w"] plural = 's' if commits > 1 else '' md.append(f"- **{repo_name}**: {commits} commit{plural}") md.append("") # Footer md.append("---\n") md.append("*Generated by ProjectTracker/tools/analyze.py*") # Write summary summary_content = "\n".join(md) SUMMARY_FILE.parent.mkdir(parents=True, exist_ok=True) with open(SUMMARY_FILE, 'w', encoding='utf-8') as f: f.write(summary_content) # Console output print(f"✅ Summary generated: {SUMMARY_FILE}") print(f"📊 {stats['total_repos']} repos scanned, {stats['needs_attention']} need attention") if __name__ == "__main__": try: generate_summary() except FileNotFoundError as e: print(f"❌ Error: File not found - {e}") print("💡 Run scan.sh first to generate state.json") exit(1) except json.JSONDecodeError as e: print(f"❌ Error: Invalid JSON - {e}") exit(1) except Exception as e: print(f"❌ Unexpected error: {e}") import traceback traceback.print_exc() exit(1)