531 lines
17 KiB
Python
531 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Manifest diffing and changelog generation between workflow versions.
|
||
|
||
Compares project_manifest.json snapshots to show:
|
||
- Added entities (pages, components, API endpoints)
|
||
- Removed entities
|
||
- Modified entities (status changes, path changes)
|
||
- Dependency changes
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Set, Tuple, Any
|
||
|
||
# Try to import yaml
|
||
try:
|
||
import yaml
|
||
HAS_YAML = True
|
||
except ImportError:
|
||
HAS_YAML = False
|
||
|
||
|
||
# ============================================================================
|
||
# File Helpers
|
||
# ============================================================================
|
||
|
||
def load_json(filepath: str) -> dict:
|
||
"""Load JSON file."""
|
||
if not os.path.exists(filepath):
|
||
return {}
|
||
with open(filepath, 'r') as f:
|
||
return json.load(f)
|
||
|
||
|
||
def load_yaml(filepath: str) -> dict:
|
||
"""Load YAML file."""
|
||
if not os.path.exists(filepath):
|
||
return {}
|
||
with open(filepath, 'r') as f:
|
||
content = f.read()
|
||
if not content.strip():
|
||
return {}
|
||
if HAS_YAML:
|
||
return yaml.safe_load(content) or {}
|
||
return {}
|
||
|
||
|
||
# ============================================================================
|
||
# Path Helpers
|
||
# ============================================================================
|
||
|
||
def get_workflow_dir() -> Path:
|
||
return Path('.workflow')
|
||
|
||
|
||
def get_version_dir(version: str) -> Path:
|
||
return get_workflow_dir() / 'versions' / version
|
||
|
||
|
||
def get_snapshot_path(version: str, snapshot_type: str) -> Path:
|
||
"""Get path to manifest snapshot for a version."""
|
||
return get_version_dir(version) / f'snapshot_{snapshot_type}' / 'manifest.json'
|
||
|
||
|
||
def get_current_manifest_path() -> Path:
|
||
return Path('project_manifest.json')
|
||
|
||
|
||
def get_versions_list() -> List[str]:
|
||
"""Get list of all versions."""
|
||
versions_dir = get_workflow_dir() / 'versions'
|
||
if not versions_dir.exists():
|
||
return []
|
||
return sorted([d.name for d in versions_dir.iterdir() if d.is_dir()])
|
||
|
||
|
||
# ============================================================================
|
||
# Entity Extraction
|
||
# ============================================================================
|
||
|
||
def extract_entities(manifest: dict) -> Dict[str, Dict[str, Any]]:
|
||
"""
|
||
Extract all entities from manifest into a flat dict keyed by ID.
|
||
|
||
Returns dict like:
|
||
{
|
||
"page_home": {"type": "page", "name": "Home", "status": "APPROVED", ...},
|
||
"component_Button": {"type": "component", "name": "Button", ...},
|
||
...
|
||
}
|
||
"""
|
||
entities = {}
|
||
|
||
entity_types = manifest.get('entities', {})
|
||
|
||
for entity_type, entity_list in entity_types.items():
|
||
if not isinstance(entity_list, list):
|
||
continue
|
||
|
||
for entity in entity_list:
|
||
entity_id = entity.get('id')
|
||
if entity_id:
|
||
entities[entity_id] = {
|
||
'type': entity_type.rstrip('s'), # pages -> page
|
||
**entity
|
||
}
|
||
|
||
return entities
|
||
|
||
|
||
# ============================================================================
|
||
# Diff Computation
|
||
# ============================================================================
|
||
|
||
def compute_diff(before: dict, after: dict) -> dict:
|
||
"""
|
||
Compute the difference between two manifests.
|
||
|
||
Returns:
|
||
{
|
||
"added": [list of added entities],
|
||
"removed": [list of removed entities],
|
||
"modified": [list of modified entities with changes],
|
||
"unchanged": [list of unchanged entity IDs]
|
||
}
|
||
"""
|
||
before_entities = extract_entities(before)
|
||
after_entities = extract_entities(after)
|
||
|
||
before_ids = set(before_entities.keys())
|
||
after_ids = set(after_entities.keys())
|
||
|
||
added_ids = after_ids - before_ids
|
||
removed_ids = before_ids - after_ids
|
||
common_ids = before_ids & after_ids
|
||
|
||
diff = {
|
||
'added': [],
|
||
'removed': [],
|
||
'modified': [],
|
||
'unchanged': []
|
||
}
|
||
|
||
# Added entities
|
||
for entity_id in sorted(added_ids):
|
||
entity = after_entities[entity_id]
|
||
diff['added'].append({
|
||
'id': entity_id,
|
||
'type': entity.get('type'),
|
||
'name': entity.get('name'),
|
||
'file_path': entity.get('file_path'),
|
||
'status': entity.get('status')
|
||
})
|
||
|
||
# Removed entities
|
||
for entity_id in sorted(removed_ids):
|
||
entity = before_entities[entity_id]
|
||
diff['removed'].append({
|
||
'id': entity_id,
|
||
'type': entity.get('type'),
|
||
'name': entity.get('name'),
|
||
'file_path': entity.get('file_path'),
|
||
'status': entity.get('status')
|
||
})
|
||
|
||
# Modified entities
|
||
for entity_id in sorted(common_ids):
|
||
before_entity = before_entities[entity_id]
|
||
after_entity = after_entities[entity_id]
|
||
|
||
changes = []
|
||
|
||
# Check each field for changes
|
||
for field in ['name', 'file_path', 'status', 'description', 'dependencies']:
|
||
before_val = before_entity.get(field)
|
||
after_val = after_entity.get(field)
|
||
|
||
if before_val != after_val:
|
||
changes.append({
|
||
'field': field,
|
||
'before': before_val,
|
||
'after': after_val
|
||
})
|
||
|
||
if changes:
|
||
diff['modified'].append({
|
||
'id': entity_id,
|
||
'type': before_entity.get('type'),
|
||
'name': after_entity.get('name'),
|
||
'file_path': after_entity.get('file_path'),
|
||
'changes': changes
|
||
})
|
||
else:
|
||
diff['unchanged'].append(entity_id)
|
||
|
||
return diff
|
||
|
||
|
||
def compute_summary(diff: dict) -> dict:
|
||
"""Compute summary statistics from diff."""
|
||
return {
|
||
'total_added': len(diff['added']),
|
||
'total_removed': len(diff['removed']),
|
||
'total_modified': len(diff['modified']),
|
||
'total_unchanged': len(diff['unchanged']),
|
||
'by_type': {
|
||
'pages': {
|
||
'added': len([e for e in diff['added'] if e['type'] == 'page']),
|
||
'removed': len([e for e in diff['removed'] if e['type'] == 'page']),
|
||
'modified': len([e for e in diff['modified'] if e['type'] == 'page'])
|
||
},
|
||
'components': {
|
||
'added': len([e for e in diff['added'] if e['type'] == 'component']),
|
||
'removed': len([e for e in diff['removed'] if e['type'] == 'component']),
|
||
'modified': len([e for e in diff['modified'] if e['type'] == 'component'])
|
||
},
|
||
'api_endpoints': {
|
||
'added': len([e for e in diff['added'] if e['type'] == 'api_endpoint']),
|
||
'removed': len([e for e in diff['removed'] if e['type'] == 'api_endpoint']),
|
||
'modified': len([e for e in diff['modified'] if e['type'] == 'api_endpoint'])
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# Display Functions
|
||
# ============================================================================
|
||
|
||
def format_entity(entity: dict, prefix: str = '') -> str:
|
||
"""Format an entity for display."""
|
||
type_icon = {
|
||
'page': '📄',
|
||
'component': '🧩',
|
||
'api_endpoint': '🔌',
|
||
'lib': '📚',
|
||
'hook': '🪝',
|
||
'type': '📝',
|
||
'config': '⚙️'
|
||
}.get(entity.get('type', ''), '•')
|
||
|
||
name = entity.get('name', entity.get('id', 'Unknown'))
|
||
file_path = entity.get('file_path', '')
|
||
|
||
return f"{prefix}{type_icon} {name} ({file_path})"
|
||
|
||
|
||
def display_diff(diff: dict, summary: dict, from_version: str, to_version: str):
|
||
"""Display diff in a formatted way."""
|
||
print()
|
||
print("╔" + "═" * 70 + "╗")
|
||
print("║" + f" MANIFEST DIFF: {from_version} → {to_version}".ljust(70) + "║")
|
||
print("╠" + "═" * 70 + "╣")
|
||
|
||
# Summary
|
||
print("║" + " SUMMARY".ljust(70) + "║")
|
||
print("║" + f" + Added: {summary['total_added']}".ljust(70) + "║")
|
||
print("║" + f" ~ Modified: {summary['total_modified']}".ljust(70) + "║")
|
||
print("║" + f" - Removed: {summary['total_removed']}".ljust(70) + "║")
|
||
print("║" + f" = Unchanged: {summary['total_unchanged']}".ljust(70) + "║")
|
||
|
||
# By type
|
||
print("╠" + "═" * 70 + "╣")
|
||
print("║" + " BY TYPE".ljust(70) + "║")
|
||
for type_name, counts in summary['by_type'].items():
|
||
changes = []
|
||
if counts['added'] > 0:
|
||
changes.append(f"+{counts['added']}")
|
||
if counts['modified'] > 0:
|
||
changes.append(f"~{counts['modified']}")
|
||
if counts['removed'] > 0:
|
||
changes.append(f"-{counts['removed']}")
|
||
if changes:
|
||
print("║" + f" {type_name}: {' '.join(changes)}".ljust(70) + "║")
|
||
|
||
# Added
|
||
if diff['added']:
|
||
print("╠" + "═" * 70 + "╣")
|
||
print("║" + " ➕ ADDED".ljust(70) + "║")
|
||
for entity in diff['added']:
|
||
line = format_entity(entity, ' + ')
|
||
print("║" + line[:70].ljust(70) + "║")
|
||
|
||
# Modified
|
||
if diff['modified']:
|
||
print("╠" + "═" * 70 + "╣")
|
||
print("║" + " 📝 MODIFIED".ljust(70) + "║")
|
||
for entity in diff['modified']:
|
||
line = format_entity(entity, ' ~ ')
|
||
print("║" + line[:70].ljust(70) + "║")
|
||
for change in entity['changes']:
|
||
field = change['field']
|
||
before = str(change['before'])[:20] if change['before'] else '(none)'
|
||
after = str(change['after'])[:20] if change['after'] else '(none)'
|
||
change_line = f" {field}: {before} → {after}"
|
||
print("║" + change_line[:70].ljust(70) + "║")
|
||
|
||
# Removed
|
||
if diff['removed']:
|
||
print("╠" + "═" * 70 + "╣")
|
||
print("║" + " ➖ REMOVED".ljust(70) + "║")
|
||
for entity in diff['removed']:
|
||
line = format_entity(entity, ' - ')
|
||
print("║" + line[:70].ljust(70) + "║")
|
||
|
||
print("╚" + "═" * 70 + "╝")
|
||
|
||
|
||
def display_changelog(version: str, session: dict, diff: dict, summary: dict):
|
||
"""Display changelog for a single version."""
|
||
print()
|
||
print("╔" + "═" * 70 + "╗")
|
||
print("║" + f" CHANGELOG: {version}".ljust(70) + "║")
|
||
print("╠" + "═" * 70 + "╣")
|
||
print("║" + f" Feature: {session.get('feature', 'Unknown')[:55]}".ljust(70) + "║")
|
||
print("║" + f" Status: {session.get('status', 'unknown')}".ljust(70) + "║")
|
||
|
||
if session.get('started_at'):
|
||
print("║" + f" Started: {session['started_at'][:19]}".ljust(70) + "║")
|
||
|
||
if session.get('completed_at'):
|
||
print("║" + f" Completed: {session['completed_at'][:19]}".ljust(70) + "║")
|
||
|
||
print("╠" + "═" * 70 + "╣")
|
||
print("║" + " CHANGES".ljust(70) + "║")
|
||
|
||
if not diff['added'] and not diff['modified'] and not diff['removed']:
|
||
print("║" + " No entity changes".ljust(70) + "║")
|
||
else:
|
||
for entity in diff['added']:
|
||
line = f" + Added {entity['type']}: {entity['name']}"
|
||
print("║" + line[:70].ljust(70) + "║")
|
||
|
||
for entity in diff['modified']:
|
||
line = f" ~ Modified {entity['type']}: {entity['name']}"
|
||
print("║" + line[:70].ljust(70) + "║")
|
||
|
||
for entity in diff['removed']:
|
||
line = f" - Removed {entity['type']}: {entity['name']}"
|
||
print("║" + line[:70].ljust(70) + "║")
|
||
|
||
print("╚" + "═" * 70 + "╝")
|
||
|
||
|
||
def output_json(data: dict):
|
||
"""Output data as JSON."""
|
||
print(json.dumps(data, indent=2))
|
||
|
||
|
||
# ============================================================================
|
||
# Commands
|
||
# ============================================================================
|
||
|
||
def diff_versions(version1: str, version2: str, output_format: str = 'text') -> int:
|
||
"""Diff two specific versions."""
|
||
# Load snapshots
|
||
before_path = get_snapshot_path(version1, 'after')
|
||
if not before_path.exists():
|
||
before_path = get_snapshot_path(version1, 'before')
|
||
|
||
after_path = get_snapshot_path(version2, 'after')
|
||
if not after_path.exists():
|
||
after_path = get_snapshot_path(version2, 'before')
|
||
|
||
if not before_path.exists():
|
||
print(f"Error: No snapshot found for version {version1}")
|
||
return 1
|
||
|
||
if not after_path.exists():
|
||
print(f"Error: No snapshot found for version {version2}")
|
||
return 1
|
||
|
||
before = load_json(str(before_path))
|
||
after = load_json(str(after_path))
|
||
|
||
diff = compute_diff(before, after)
|
||
summary = compute_summary(diff)
|
||
|
||
if output_format == 'json':
|
||
output_json({
|
||
'from_version': version1,
|
||
'to_version': version2,
|
||
'diff': diff,
|
||
'summary': summary
|
||
})
|
||
else:
|
||
display_diff(diff, summary, version1, version2)
|
||
|
||
return 0
|
||
|
||
|
||
def diff_with_current(version: str, output_format: str = 'text') -> int:
|
||
"""Diff a version with current manifest."""
|
||
# Load version snapshot
|
||
snapshot_path = get_snapshot_path(version, 'before')
|
||
if not snapshot_path.exists():
|
||
print(f"Error: No snapshot found for version {version}")
|
||
return 1
|
||
|
||
before = load_json(str(snapshot_path))
|
||
|
||
# Load current manifest
|
||
current_path = get_current_manifest_path()
|
||
if not current_path.exists():
|
||
print("Error: No current manifest found")
|
||
return 1
|
||
|
||
after = load_json(str(current_path))
|
||
|
||
diff = compute_diff(before, after)
|
||
summary = compute_summary(diff)
|
||
|
||
if output_format == 'json':
|
||
output_json({
|
||
'from_version': version,
|
||
'to_version': 'current',
|
||
'diff': diff,
|
||
'summary': summary
|
||
})
|
||
else:
|
||
display_diff(diff, summary, version, 'current')
|
||
|
||
return 0
|
||
|
||
|
||
def show_changelog(version: str = None, output_format: str = 'text') -> int:
|
||
"""Show changelog for a version or all versions."""
|
||
versions = get_versions_list()
|
||
|
||
if not versions:
|
||
print("No workflow versions found.")
|
||
return 1
|
||
|
||
if version:
|
||
versions = [v for v in versions if v == version]
|
||
if not versions:
|
||
print(f"Version {version} not found.")
|
||
return 1
|
||
|
||
for i, v in enumerate(versions):
|
||
# Load session info
|
||
session_path = get_version_dir(v) / 'session.yml'
|
||
session = load_yaml(str(session_path)) if session_path.exists() else {}
|
||
|
||
# Get before/after snapshots
|
||
before_path = get_snapshot_path(v, 'before')
|
||
after_path = get_snapshot_path(v, 'after')
|
||
|
||
before = load_json(str(before_path)) if before_path.exists() else {}
|
||
after = load_json(str(after_path)) if after_path.exists() else {}
|
||
|
||
if not after:
|
||
after = before # Use before if no after exists
|
||
|
||
diff = compute_diff(before, after)
|
||
summary = compute_summary(diff)
|
||
|
||
if output_format == 'json':
|
||
output_json({
|
||
'version': v,
|
||
'session': session,
|
||
'diff': diff,
|
||
'summary': summary
|
||
})
|
||
else:
|
||
display_changelog(v, session, diff, summary)
|
||
|
||
return 0
|
||
|
||
|
||
# ============================================================================
|
||
# CLI Interface
|
||
# ============================================================================
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Manifest diffing and changelog generation")
|
||
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
||
|
||
# diff command
|
||
diff_parser = subparsers.add_parser('diff', help='Diff two versions')
|
||
diff_parser.add_argument('version1', help='First version')
|
||
diff_parser.add_argument('version2', nargs='?', help='Second version (or "current")')
|
||
diff_parser.add_argument('--json', action='store_true', help='Output as JSON')
|
||
|
||
# changelog command
|
||
changelog_parser = subparsers.add_parser('changelog', help='Show version changelog')
|
||
changelog_parser.add_argument('version', nargs='?', help='Specific version (or all)')
|
||
changelog_parser.add_argument('--json', action='store_true', help='Output as JSON')
|
||
|
||
# versions command
|
||
subparsers.add_parser('versions', help='List all versions')
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.command == 'diff':
|
||
output_format = 'json' if args.json else 'text'
|
||
|
||
if args.version2:
|
||
if args.version2 == 'current':
|
||
sys.exit(diff_with_current(args.version1, output_format))
|
||
else:
|
||
sys.exit(diff_versions(args.version1, args.version2, output_format))
|
||
else:
|
||
# Diff with current by default
|
||
sys.exit(diff_with_current(args.version1, output_format))
|
||
|
||
elif args.command == 'changelog':
|
||
output_format = 'json' if args.json else 'text'
|
||
sys.exit(show_changelog(args.version, output_format))
|
||
|
||
elif args.command == 'versions':
|
||
versions = get_versions_list()
|
||
if versions:
|
||
print("\nAvailable versions:")
|
||
for v in versions:
|
||
print(f" - {v}")
|
||
else:
|
||
print("No versions found.")
|
||
|
||
else:
|
||
parser.print_help()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|