Multi-repository state tracker and coordinator for managing 18+ Git projects. Core Components: - tools/scan.sh: Bash script to scan all repos, pull updates, detect conflicts - tools/analyze.py: Python generator for human-readable summaries - tools/config.json: Repository tracking configuration - README.md: Complete documentation Features: - State tracking (uncommitted changes, new commits, conflicts) - Activity monitoring (3-week lookback window) - Automatic pulling with conflict detection - Categorization (META, CONSTANT, WIP, CONCEPT, PAUSE, DONE) - Summary generation with prioritization analyze.py created in Session 7 - Cycle 2 (ClaudeSelf external exploration). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
208 lines
6.3 KiB
Python
208 lines
6.3 KiB
Python
#!/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)
|