project-standalo-sonic-cloud/skills/guardrail-orchestrator/scripts/manifest_diff.py

531 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()