1003 lines
38 KiB
Python
1003 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Release Planner
|
|
|
|
Takes a list of features/PRs/tickets planned for release and assesses release readiness.
|
|
Checks for required approvals, test coverage thresholds, breaking change documentation,
|
|
dependency updates, migration steps needed. Generates release checklist, communication
|
|
plan, and rollback procedures.
|
|
|
|
Input: release plan JSON (features, PRs, target date)
|
|
Output: release readiness report + checklist + rollback runbook + announcement draft
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Any, Union
|
|
from dataclasses import dataclass, asdict
|
|
from enum import Enum
|
|
|
|
|
|
class RiskLevel(Enum):
|
|
"""Risk levels for release components."""
|
|
LOW = "low"
|
|
MEDIUM = "medium"
|
|
HIGH = "high"
|
|
CRITICAL = "critical"
|
|
|
|
|
|
class ComponentStatus(Enum):
|
|
"""Status of release components."""
|
|
PENDING = "pending"
|
|
IN_PROGRESS = "in_progress"
|
|
READY = "ready"
|
|
BLOCKED = "blocked"
|
|
FAILED = "failed"
|
|
|
|
|
|
@dataclass
|
|
class Feature:
|
|
"""Represents a feature in the release."""
|
|
id: str
|
|
title: str
|
|
description: str
|
|
type: str # feature, bugfix, security, breaking_change, etc.
|
|
assignee: str
|
|
status: ComponentStatus
|
|
pull_request_url: Optional[str] = None
|
|
issue_url: Optional[str] = None
|
|
risk_level: RiskLevel = RiskLevel.MEDIUM
|
|
test_coverage_required: float = 80.0
|
|
test_coverage_actual: Optional[float] = None
|
|
requires_migration: bool = False
|
|
migration_complexity: str = "simple" # simple, moderate, complex
|
|
breaking_changes: List[str] = None
|
|
dependencies: List[str] = None
|
|
qa_approved: bool = False
|
|
security_approved: bool = False
|
|
pm_approved: bool = False
|
|
|
|
def __post_init__(self):
|
|
if self.breaking_changes is None:
|
|
self.breaking_changes = []
|
|
if self.dependencies is None:
|
|
self.dependencies = []
|
|
|
|
|
|
@dataclass
|
|
class QualityGate:
|
|
"""Quality gate requirements."""
|
|
name: str
|
|
required: bool
|
|
status: ComponentStatus
|
|
details: Optional[str] = None
|
|
threshold: Optional[float] = None
|
|
actual_value: Optional[float] = None
|
|
|
|
|
|
@dataclass
|
|
class Stakeholder:
|
|
"""Stakeholder for release communication."""
|
|
name: str
|
|
role: str
|
|
contact: str
|
|
notification_type: str # email, slack, teams
|
|
critical_path: bool = False
|
|
|
|
|
|
@dataclass
|
|
class RollbackStep:
|
|
"""Individual rollback step."""
|
|
order: int
|
|
description: str
|
|
command: Optional[str] = None
|
|
estimated_time: str = "5 minutes"
|
|
risk_level: RiskLevel = RiskLevel.LOW
|
|
verification: str = ""
|
|
|
|
|
|
class ReleasePlanner:
|
|
"""Main release planning and assessment logic."""
|
|
|
|
def __init__(self):
|
|
self.release_name: str = ""
|
|
self.version: str = ""
|
|
self.target_date: Optional[datetime] = None
|
|
self.features: List[Feature] = []
|
|
self.quality_gates: List[QualityGate] = []
|
|
self.stakeholders: List[Stakeholder] = []
|
|
self.rollback_steps: List[RollbackStep] = []
|
|
|
|
# Configuration
|
|
self.min_test_coverage = 80.0
|
|
self.required_approvals = ['pm_approved', 'qa_approved']
|
|
self.high_risk_approval_requirements = ['pm_approved', 'qa_approved', 'security_approved']
|
|
|
|
def load_release_plan(self, plan_data: Union[str, Dict]):
|
|
"""Load release plan from JSON."""
|
|
if isinstance(plan_data, str):
|
|
data = json.loads(plan_data)
|
|
else:
|
|
data = plan_data
|
|
|
|
self.release_name = data.get('release_name', 'Unnamed Release')
|
|
self.version = data.get('version', '1.0.0')
|
|
|
|
if 'target_date' in data:
|
|
self.target_date = datetime.fromisoformat(data['target_date'].replace('Z', '+00:00'))
|
|
|
|
# Load features
|
|
self.features = []
|
|
for feature_data in data.get('features', []):
|
|
try:
|
|
status = ComponentStatus(feature_data.get('status', 'pending'))
|
|
risk_level = RiskLevel(feature_data.get('risk_level', 'medium'))
|
|
|
|
feature = Feature(
|
|
id=feature_data['id'],
|
|
title=feature_data['title'],
|
|
description=feature_data.get('description', ''),
|
|
type=feature_data.get('type', 'feature'),
|
|
assignee=feature_data.get('assignee', ''),
|
|
status=status,
|
|
pull_request_url=feature_data.get('pull_request_url'),
|
|
issue_url=feature_data.get('issue_url'),
|
|
risk_level=risk_level,
|
|
test_coverage_required=feature_data.get('test_coverage_required', 80.0),
|
|
test_coverage_actual=feature_data.get('test_coverage_actual'),
|
|
requires_migration=feature_data.get('requires_migration', False),
|
|
migration_complexity=feature_data.get('migration_complexity', 'simple'),
|
|
breaking_changes=feature_data.get('breaking_changes', []),
|
|
dependencies=feature_data.get('dependencies', []),
|
|
qa_approved=feature_data.get('qa_approved', False),
|
|
security_approved=feature_data.get('security_approved', False),
|
|
pm_approved=feature_data.get('pm_approved', False)
|
|
)
|
|
self.features.append(feature)
|
|
except Exception as e:
|
|
print(f"Warning: Error parsing feature {feature_data.get('id', 'unknown')}: {e}",
|
|
file=sys.stderr)
|
|
|
|
# Load quality gates
|
|
self.quality_gates = []
|
|
for gate_data in data.get('quality_gates', []):
|
|
try:
|
|
status = ComponentStatus(gate_data.get('status', 'pending'))
|
|
gate = QualityGate(
|
|
name=gate_data['name'],
|
|
required=gate_data.get('required', True),
|
|
status=status,
|
|
details=gate_data.get('details'),
|
|
threshold=gate_data.get('threshold'),
|
|
actual_value=gate_data.get('actual_value')
|
|
)
|
|
self.quality_gates.append(gate)
|
|
except Exception as e:
|
|
print(f"Warning: Error parsing quality gate {gate_data.get('name', 'unknown')}: {e}",
|
|
file=sys.stderr)
|
|
|
|
# Load stakeholders
|
|
self.stakeholders = []
|
|
for stakeholder_data in data.get('stakeholders', []):
|
|
stakeholder = Stakeholder(
|
|
name=stakeholder_data['name'],
|
|
role=stakeholder_data['role'],
|
|
contact=stakeholder_data['contact'],
|
|
notification_type=stakeholder_data.get('notification_type', 'email'),
|
|
critical_path=stakeholder_data.get('critical_path', False)
|
|
)
|
|
self.stakeholders.append(stakeholder)
|
|
|
|
# Load or generate default quality gates if none provided
|
|
if not self.quality_gates:
|
|
self._generate_default_quality_gates()
|
|
|
|
# Load or generate default rollback steps
|
|
if 'rollback_steps' in data:
|
|
self.rollback_steps = []
|
|
for step_data in data['rollback_steps']:
|
|
risk_level = RiskLevel(step_data.get('risk_level', 'low'))
|
|
step = RollbackStep(
|
|
order=step_data['order'],
|
|
description=step_data['description'],
|
|
command=step_data.get('command'),
|
|
estimated_time=step_data.get('estimated_time', '5 minutes'),
|
|
risk_level=risk_level,
|
|
verification=step_data.get('verification', '')
|
|
)
|
|
self.rollback_steps.append(step)
|
|
else:
|
|
self._generate_default_rollback_steps()
|
|
|
|
def _generate_default_quality_gates(self):
|
|
"""Generate default quality gates."""
|
|
default_gates = [
|
|
{
|
|
'name': 'Unit Test Coverage',
|
|
'required': True,
|
|
'threshold': self.min_test_coverage,
|
|
'details': f'Minimum {self.min_test_coverage}% code coverage required'
|
|
},
|
|
{
|
|
'name': 'Integration Tests',
|
|
'required': True,
|
|
'details': 'All integration tests must pass'
|
|
},
|
|
{
|
|
'name': 'Security Scan',
|
|
'required': True,
|
|
'details': 'No high or critical security vulnerabilities'
|
|
},
|
|
{
|
|
'name': 'Performance Testing',
|
|
'required': True,
|
|
'details': 'Performance metrics within acceptable thresholds'
|
|
},
|
|
{
|
|
'name': 'Documentation Review',
|
|
'required': True,
|
|
'details': 'API docs and user docs updated for new features'
|
|
},
|
|
{
|
|
'name': 'Dependency Audit',
|
|
'required': True,
|
|
'details': 'All dependencies scanned for vulnerabilities'
|
|
}
|
|
]
|
|
|
|
self.quality_gates = []
|
|
for gate_data in default_gates:
|
|
gate = QualityGate(
|
|
name=gate_data['name'],
|
|
required=gate_data['required'],
|
|
status=ComponentStatus.PENDING,
|
|
details=gate_data['details'],
|
|
threshold=gate_data.get('threshold')
|
|
)
|
|
self.quality_gates.append(gate)
|
|
|
|
def _generate_default_rollback_steps(self):
|
|
"""Generate default rollback procedure."""
|
|
default_steps = [
|
|
{
|
|
'order': 1,
|
|
'description': 'Alert on-call team and stakeholders',
|
|
'estimated_time': '2 minutes',
|
|
'verification': 'Confirm team is aware and responding'
|
|
},
|
|
{
|
|
'order': 2,
|
|
'description': 'Switch load balancer to previous version',
|
|
'command': 'kubectl patch service app --patch \'{"spec": {"selector": {"version": "previous"}}}\'',
|
|
'estimated_time': '30 seconds',
|
|
'verification': 'Check that traffic is routing to old version'
|
|
},
|
|
{
|
|
'order': 3,
|
|
'description': 'Verify application health after rollback',
|
|
'estimated_time': '5 minutes',
|
|
'verification': 'Check error rates, response times, and health endpoints'
|
|
},
|
|
{
|
|
'order': 4,
|
|
'description': 'Roll back database migrations if needed',
|
|
'command': 'python manage.py migrate app 0001',
|
|
'estimated_time': '10 minutes',
|
|
'risk_level': 'high',
|
|
'verification': 'Verify data integrity and application functionality'
|
|
},
|
|
{
|
|
'order': 5,
|
|
'description': 'Update monitoring dashboards and alerts',
|
|
'estimated_time': '5 minutes',
|
|
'verification': 'Confirm metrics reflect rollback state'
|
|
},
|
|
{
|
|
'order': 6,
|
|
'description': 'Notify stakeholders of successful rollback',
|
|
'estimated_time': '5 minutes',
|
|
'verification': 'All stakeholders acknowledge rollback completion'
|
|
}
|
|
]
|
|
|
|
self.rollback_steps = []
|
|
for step_data in default_steps:
|
|
risk_level = RiskLevel(step_data.get('risk_level', 'low'))
|
|
step = RollbackStep(
|
|
order=step_data['order'],
|
|
description=step_data['description'],
|
|
command=step_data.get('command'),
|
|
estimated_time=step_data.get('estimated_time', '5 minutes'),
|
|
risk_level=risk_level,
|
|
verification=step_data.get('verification', '')
|
|
)
|
|
self.rollback_steps.append(step)
|
|
|
|
def assess_release_readiness(self) -> Dict:
|
|
"""Assess overall release readiness."""
|
|
assessment = {
|
|
'overall_status': 'ready',
|
|
'readiness_score': 0.0,
|
|
'blocking_issues': [],
|
|
'warnings': [],
|
|
'recommendations': [],
|
|
'feature_summary': {},
|
|
'quality_gate_summary': {},
|
|
'timeline_assessment': {}
|
|
}
|
|
|
|
total_score = 0
|
|
max_score = 0
|
|
|
|
# Assess features
|
|
feature_stats = {
|
|
'total': len(self.features),
|
|
'ready': 0,
|
|
'blocked': 0,
|
|
'in_progress': 0,
|
|
'pending': 0,
|
|
'high_risk': 0,
|
|
'breaking_changes': 0,
|
|
'missing_approvals': 0,
|
|
'low_test_coverage': 0
|
|
}
|
|
|
|
for feature in self.features:
|
|
max_score += 10 # Each feature worth 10 points
|
|
|
|
if feature.status == ComponentStatus.READY:
|
|
feature_stats['ready'] += 1
|
|
total_score += 10
|
|
elif feature.status == ComponentStatus.BLOCKED:
|
|
feature_stats['blocked'] += 1
|
|
assessment['blocking_issues'].append(
|
|
f"Feature '{feature.title}' ({feature.id}) is blocked"
|
|
)
|
|
elif feature.status == ComponentStatus.IN_PROGRESS:
|
|
feature_stats['in_progress'] += 1
|
|
total_score += 5 # Partial credit
|
|
assessment['warnings'].append(
|
|
f"Feature '{feature.title}' ({feature.id}) still in progress"
|
|
)
|
|
else:
|
|
feature_stats['pending'] += 1
|
|
assessment['warnings'].append(
|
|
f"Feature '{feature.title}' ({feature.id}) is pending"
|
|
)
|
|
|
|
# Check risk level
|
|
if feature.risk_level in [RiskLevel.HIGH, RiskLevel.CRITICAL]:
|
|
feature_stats['high_risk'] += 1
|
|
|
|
# Check breaking changes
|
|
if feature.breaking_changes:
|
|
feature_stats['breaking_changes'] += 1
|
|
|
|
# Check approvals
|
|
missing_approvals = self._check_feature_approvals(feature)
|
|
if missing_approvals:
|
|
feature_stats['missing_approvals'] += 1
|
|
assessment['blocking_issues'].append(
|
|
f"Feature '{feature.title}' missing approvals: {', '.join(missing_approvals)}"
|
|
)
|
|
|
|
# Check test coverage
|
|
if (feature.test_coverage_actual is not None and
|
|
feature.test_coverage_actual < feature.test_coverage_required):
|
|
feature_stats['low_test_coverage'] += 1
|
|
assessment['warnings'].append(
|
|
f"Feature '{feature.title}' has low test coverage: "
|
|
f"{feature.test_coverage_actual}% < {feature.test_coverage_required}%"
|
|
)
|
|
|
|
assessment['feature_summary'] = feature_stats
|
|
|
|
# Assess quality gates
|
|
gate_stats = {
|
|
'total': len(self.quality_gates),
|
|
'passed': 0,
|
|
'failed': 0,
|
|
'pending': 0,
|
|
'required_failed': 0
|
|
}
|
|
|
|
for gate in self.quality_gates:
|
|
max_score += 5 # Each gate worth 5 points
|
|
|
|
if gate.status == ComponentStatus.READY:
|
|
gate_stats['passed'] += 1
|
|
total_score += 5
|
|
elif gate.status == ComponentStatus.FAILED:
|
|
gate_stats['failed'] += 1
|
|
if gate.required:
|
|
gate_stats['required_failed'] += 1
|
|
assessment['blocking_issues'].append(
|
|
f"Required quality gate '{gate.name}' failed"
|
|
)
|
|
else:
|
|
gate_stats['pending'] += 1
|
|
if gate.required:
|
|
assessment['warnings'].append(
|
|
f"Required quality gate '{gate.name}' is pending"
|
|
)
|
|
|
|
assessment['quality_gate_summary'] = gate_stats
|
|
|
|
# Timeline assessment
|
|
if self.target_date:
|
|
# Handle timezone-aware datetime comparison
|
|
now = datetime.now(self.target_date.tzinfo) if self.target_date.tzinfo else datetime.now()
|
|
days_until_release = (self.target_date - now).days
|
|
assessment['timeline_assessment'] = {
|
|
'target_date': self.target_date.isoformat(),
|
|
'days_remaining': days_until_release,
|
|
'timeline_status': 'on_track' if days_until_release > 0 else 'overdue'
|
|
}
|
|
|
|
if days_until_release < 0:
|
|
assessment['blocking_issues'].append(f"Release is {abs(days_until_release)} days overdue")
|
|
elif days_until_release < 3 and feature_stats['blocked'] > 0:
|
|
assessment['blocking_issues'].append("Not enough time to resolve blocked features")
|
|
|
|
# Calculate overall readiness score
|
|
if max_score > 0:
|
|
assessment['readiness_score'] = (total_score / max_score) * 100
|
|
|
|
# Determine overall status
|
|
if assessment['blocking_issues']:
|
|
assessment['overall_status'] = 'blocked'
|
|
elif assessment['warnings']:
|
|
assessment['overall_status'] = 'at_risk'
|
|
else:
|
|
assessment['overall_status'] = 'ready'
|
|
|
|
# Generate recommendations
|
|
if feature_stats['missing_approvals'] > 0:
|
|
assessment['recommendations'].append("Obtain required approvals for pending features")
|
|
|
|
if feature_stats['low_test_coverage'] > 0:
|
|
assessment['recommendations'].append("Improve test coverage for features below threshold")
|
|
|
|
if gate_stats['pending'] > 0:
|
|
assessment['recommendations'].append("Complete pending quality gate validations")
|
|
|
|
if feature_stats['high_risk'] > 0:
|
|
assessment['recommendations'].append("Review high-risk features for additional validation")
|
|
|
|
return assessment
|
|
|
|
def _check_feature_approvals(self, feature: Feature) -> List[str]:
|
|
"""Check which approvals are missing for a feature."""
|
|
missing = []
|
|
|
|
# Determine required approvals based on risk level
|
|
required = self.required_approvals.copy()
|
|
if feature.risk_level in [RiskLevel.HIGH, RiskLevel.CRITICAL]:
|
|
required = self.high_risk_approval_requirements.copy()
|
|
|
|
if 'pm_approved' in required and not feature.pm_approved:
|
|
missing.append('PM approval')
|
|
|
|
if 'qa_approved' in required and not feature.qa_approved:
|
|
missing.append('QA approval')
|
|
|
|
if 'security_approved' in required and not feature.security_approved:
|
|
missing.append('Security approval')
|
|
|
|
return missing
|
|
|
|
def generate_release_checklist(self) -> List[Dict]:
|
|
"""Generate comprehensive release checklist."""
|
|
checklist = []
|
|
|
|
# Pre-release validation
|
|
checklist.extend([
|
|
{
|
|
'category': 'Pre-Release Validation',
|
|
'item': 'All features implemented and tested',
|
|
'status': 'ready' if all(f.status == ComponentStatus.READY for f in self.features) else 'pending',
|
|
'details': f"{len([f for f in self.features if f.status == ComponentStatus.READY])}/{len(self.features)} features ready"
|
|
},
|
|
{
|
|
'category': 'Pre-Release Validation',
|
|
'item': 'Breaking changes documented',
|
|
'status': 'ready' if self._check_breaking_change_docs() else 'pending',
|
|
'details': f"{len([f for f in self.features if f.breaking_changes])} features have breaking changes"
|
|
},
|
|
{
|
|
'category': 'Pre-Release Validation',
|
|
'item': 'Migration scripts tested',
|
|
'status': 'ready' if self._check_migrations() else 'pending',
|
|
'details': f"{len([f for f in self.features if f.requires_migration])} features require migrations"
|
|
}
|
|
])
|
|
|
|
# Quality gates
|
|
for gate in self.quality_gates:
|
|
checklist.append({
|
|
'category': 'Quality Gates',
|
|
'item': gate.name,
|
|
'status': gate.status.value,
|
|
'details': gate.details,
|
|
'required': gate.required
|
|
})
|
|
|
|
# Approvals
|
|
approval_items = [
|
|
('Product Manager sign-off', self._check_pm_approvals()),
|
|
('QA validation complete', self._check_qa_approvals()),
|
|
('Security team clearance', self._check_security_approvals())
|
|
]
|
|
|
|
for item, status in approval_items:
|
|
checklist.append({
|
|
'category': 'Approvals',
|
|
'item': item,
|
|
'status': 'ready' if status else 'pending'
|
|
})
|
|
|
|
# Documentation
|
|
doc_items = [
|
|
'CHANGELOG.md updated',
|
|
'API documentation updated',
|
|
'User documentation updated',
|
|
'Migration guide written',
|
|
'Rollback procedure documented'
|
|
]
|
|
|
|
for item in doc_items:
|
|
checklist.append({
|
|
'category': 'Documentation',
|
|
'item': item,
|
|
'status': 'pending' # Would need integration with docs system to check
|
|
})
|
|
|
|
# Deployment preparation
|
|
deployment_items = [
|
|
'Database migrations prepared',
|
|
'Environment variables configured',
|
|
'Monitoring alerts updated',
|
|
'Rollback plan tested',
|
|
'Stakeholders notified'
|
|
]
|
|
|
|
for item in deployment_items:
|
|
checklist.append({
|
|
'category': 'Deployment',
|
|
'item': item,
|
|
'status': 'pending'
|
|
})
|
|
|
|
return checklist
|
|
|
|
def _check_breaking_change_docs(self) -> bool:
|
|
"""Check if breaking changes are properly documented."""
|
|
features_with_breaking_changes = [f for f in self.features if f.breaking_changes]
|
|
return all(len(f.breaking_changes) > 0 for f in features_with_breaking_changes)
|
|
|
|
def _check_migrations(self) -> bool:
|
|
"""Check migration readiness."""
|
|
features_with_migrations = [f for f in self.features if f.requires_migration]
|
|
return all(f.status == ComponentStatus.READY for f in features_with_migrations)
|
|
|
|
def _check_pm_approvals(self) -> bool:
|
|
"""Check PM approvals."""
|
|
return all(f.pm_approved for f in self.features if f.risk_level != RiskLevel.LOW)
|
|
|
|
def _check_qa_approvals(self) -> bool:
|
|
"""Check QA approvals."""
|
|
return all(f.qa_approved for f in self.features)
|
|
|
|
def _check_security_approvals(self) -> bool:
|
|
"""Check security approvals."""
|
|
high_risk_features = [f for f in self.features if f.risk_level in [RiskLevel.HIGH, RiskLevel.CRITICAL]]
|
|
return all(f.security_approved for f in high_risk_features)
|
|
|
|
def generate_communication_plan(self) -> Dict:
|
|
"""Generate stakeholder communication plan."""
|
|
plan = {
|
|
'internal_notifications': [],
|
|
'external_notifications': [],
|
|
'timeline': [],
|
|
'channels': {},
|
|
'templates': {}
|
|
}
|
|
|
|
# Group stakeholders by type
|
|
internal_stakeholders = [s for s in self.stakeholders if s.role in
|
|
['developer', 'qa', 'pm', 'devops', 'security']]
|
|
external_stakeholders = [s for s in self.stakeholders if s.role in
|
|
['customer', 'partner', 'support']]
|
|
|
|
# Internal notifications
|
|
for stakeholder in internal_stakeholders:
|
|
plan['internal_notifications'].append({
|
|
'recipient': stakeholder.name,
|
|
'role': stakeholder.role,
|
|
'method': stakeholder.notification_type,
|
|
'content_type': 'technical_details',
|
|
'timing': 'T-24h and T-0'
|
|
})
|
|
|
|
# External notifications
|
|
for stakeholder in external_stakeholders:
|
|
plan['external_notifications'].append({
|
|
'recipient': stakeholder.name,
|
|
'role': stakeholder.role,
|
|
'method': stakeholder.notification_type,
|
|
'content_type': 'user_facing_changes',
|
|
'timing': 'T-48h and T+1h'
|
|
})
|
|
|
|
# Communication timeline
|
|
if self.target_date:
|
|
timeline_items = [
|
|
(timedelta(days=-2), 'Send pre-release notification to external stakeholders'),
|
|
(timedelta(days=-1), 'Send deployment notification to internal teams'),
|
|
(timedelta(hours=-2), 'Final go/no-go decision'),
|
|
(timedelta(hours=0), 'Begin deployment'),
|
|
(timedelta(hours=1), 'Post-deployment status update'),
|
|
(timedelta(hours=24), 'Post-release summary')
|
|
]
|
|
|
|
for delta, description in timeline_items:
|
|
notification_time = self.target_date + delta
|
|
plan['timeline'].append({
|
|
'time': notification_time.isoformat(),
|
|
'description': description,
|
|
'recipients': 'all' if 'all' in description.lower() else 'internal'
|
|
})
|
|
|
|
# Communication channels
|
|
channels = {}
|
|
for stakeholder in self.stakeholders:
|
|
if stakeholder.notification_type not in channels:
|
|
channels[stakeholder.notification_type] = []
|
|
channels[stakeholder.notification_type].append(stakeholder.contact)
|
|
plan['channels'] = channels
|
|
|
|
# Message templates
|
|
plan['templates'] = self._generate_message_templates()
|
|
|
|
return plan
|
|
|
|
def _generate_message_templates(self) -> Dict:
|
|
"""Generate message templates for different audiences."""
|
|
breaking_changes = [f for f in self.features if f.breaking_changes]
|
|
new_features = [f for f in self.features if f.type == 'feature']
|
|
bug_fixes = [f for f in self.features if f.type == 'bugfix']
|
|
|
|
templates = {
|
|
'internal_pre_release': {
|
|
'subject': f'Release {self.version} - Pre-deployment Notification',
|
|
'body': f"""Team,
|
|
|
|
We are preparing to deploy {self.release_name} version {self.version} on {self.target_date.strftime('%Y-%m-%d %H:%M UTC') if self.target_date else 'TBD'}.
|
|
|
|
Key Changes:
|
|
- {len(new_features)} new features
|
|
- {len(bug_fixes)} bug fixes
|
|
- {len(breaking_changes)} breaking changes
|
|
|
|
Please review the release notes and prepare for any needed support activities.
|
|
|
|
Rollback plan: Available in release documentation
|
|
On-call: Please be available during deployment window
|
|
|
|
Best regards,
|
|
Release Team"""
|
|
},
|
|
'external_user_notification': {
|
|
'subject': f'Product Update - Version {self.version} Now Available',
|
|
'body': f"""Dear Users,
|
|
|
|
We're excited to announce version {self.version} of {self.release_name} is now available!
|
|
|
|
What's New:
|
|
{chr(10).join(f"- {f.title}" for f in new_features[:5])}
|
|
|
|
Bug Fixes:
|
|
{chr(10).join(f"- {f.title}" for f in bug_fixes[:3])}
|
|
|
|
{'Important: This release includes breaking changes. Please review the migration guide.' if breaking_changes else ''}
|
|
|
|
For full release notes and migration instructions, visit our documentation.
|
|
|
|
Thank you for using our product!
|
|
|
|
The Development Team"""
|
|
},
|
|
'rollback_notification': {
|
|
'subject': f'URGENT: Release {self.version} Rollback Initiated',
|
|
'body': f"""ATTENTION: Release rollback in progress.
|
|
|
|
Release: {self.version}
|
|
Reason: [TO BE FILLED]
|
|
Rollback initiated: {datetime.now().strftime('%Y-%m-%d %H:%M UTC')}
|
|
Estimated completion: [TO BE FILLED]
|
|
|
|
Current status: Rolling back to previous stable version
|
|
Impact: [TO BE FILLED]
|
|
|
|
We will provide updates every 15 minutes until rollback is complete.
|
|
|
|
Incident Commander: [TO BE FILLED]
|
|
Status page: [TO BE FILLED]"""
|
|
}
|
|
}
|
|
|
|
return templates
|
|
|
|
def generate_rollback_runbook(self) -> Dict:
|
|
"""Generate detailed rollback runbook."""
|
|
runbook = {
|
|
'overview': {
|
|
'purpose': f'Emergency rollback procedure for {self.release_name} v{self.version}',
|
|
'triggers': [
|
|
'Error rate spike (>2x baseline for >15 minutes)',
|
|
'Critical functionality failure',
|
|
'Security incident',
|
|
'Data corruption detected',
|
|
'Performance degradation (>50% latency increase)',
|
|
'Manual decision by incident commander'
|
|
],
|
|
'decision_makers': ['On-call Engineer', 'Engineering Lead', 'Incident Commander'],
|
|
'estimated_total_time': self._calculate_rollback_time()
|
|
},
|
|
'prerequisites': [
|
|
'Confirm rollback is necessary (check with incident commander)',
|
|
'Notify stakeholders of rollback decision',
|
|
'Ensure database backups are available',
|
|
'Verify monitoring systems are operational',
|
|
'Have communication channels ready'
|
|
],
|
|
'steps': [],
|
|
'verification': {
|
|
'health_checks': [
|
|
'Application responds to health endpoint',
|
|
'Database connectivity confirmed',
|
|
'Authentication system functional',
|
|
'Core user workflows working',
|
|
'Error rates back to baseline',
|
|
'Performance metrics within normal range'
|
|
],
|
|
'rollback_confirmation': [
|
|
'Previous version fully deployed',
|
|
'Database in consistent state',
|
|
'All services communicating properly',
|
|
'Monitoring shows stable metrics',
|
|
'Sample user workflows tested'
|
|
]
|
|
},
|
|
'post_rollback': [
|
|
'Update status page with resolution',
|
|
'Notify all stakeholders of successful rollback',
|
|
'Schedule post-incident review',
|
|
'Document issues encountered during rollback',
|
|
'Plan investigation of root cause',
|
|
'Determine timeline for next release attempt'
|
|
],
|
|
'emergency_contacts': []
|
|
}
|
|
|
|
# Convert rollback steps to detailed format
|
|
for step in sorted(self.rollback_steps, key=lambda x: x.order):
|
|
step_data = {
|
|
'order': step.order,
|
|
'title': step.description,
|
|
'estimated_time': step.estimated_time,
|
|
'risk_level': step.risk_level.value,
|
|
'instructions': step.description,
|
|
'command': step.command,
|
|
'verification': step.verification,
|
|
'rollback_possible': step.risk_level != RiskLevel.CRITICAL
|
|
}
|
|
runbook['steps'].append(step_data)
|
|
|
|
# Add emergency contacts
|
|
critical_stakeholders = [s for s in self.stakeholders if s.critical_path]
|
|
for stakeholder in critical_stakeholders:
|
|
runbook['emergency_contacts'].append({
|
|
'name': stakeholder.name,
|
|
'role': stakeholder.role,
|
|
'contact': stakeholder.contact,
|
|
'method': stakeholder.notification_type
|
|
})
|
|
|
|
return runbook
|
|
|
|
def _calculate_rollback_time(self) -> str:
|
|
"""Calculate estimated total rollback time."""
|
|
total_minutes = 0
|
|
for step in self.rollback_steps:
|
|
# Parse time estimates like "5 minutes", "30 seconds", "1 hour"
|
|
time_str = step.estimated_time.lower()
|
|
if 'minute' in time_str:
|
|
minutes = int(re.search(r'(\d+)', time_str).group(1))
|
|
total_minutes += minutes
|
|
elif 'hour' in time_str:
|
|
hours = int(re.search(r'(\d+)', time_str).group(1))
|
|
total_minutes += hours * 60
|
|
elif 'second' in time_str:
|
|
# Round up seconds to minutes
|
|
total_minutes += 1
|
|
|
|
if total_minutes < 60:
|
|
return f"{total_minutes} minutes"
|
|
else:
|
|
hours = total_minutes // 60
|
|
minutes = total_minutes % 60
|
|
return f"{hours}h {minutes}m"
|
|
|
|
|
|
def main():
|
|
"""Main CLI entry point."""
|
|
parser = argparse.ArgumentParser(description="Assess release readiness and generate release plans")
|
|
parser.add_argument('--input', '-i', required=True,
|
|
help='Release plan JSON file')
|
|
parser.add_argument('--output-format', '-f',
|
|
choices=['json', 'markdown', 'text'],
|
|
default='text', help='Output format')
|
|
parser.add_argument('--output', '-o', type=str,
|
|
help='Output file (default: stdout)')
|
|
parser.add_argument('--include-checklist', action='store_true',
|
|
help='Include release checklist in output')
|
|
parser.add_argument('--include-communication', action='store_true',
|
|
help='Include communication plan')
|
|
parser.add_argument('--include-rollback', action='store_true',
|
|
help='Include rollback runbook')
|
|
parser.add_argument('--min-coverage', type=float, default=80.0,
|
|
help='Minimum test coverage threshold')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Load release plan
|
|
try:
|
|
with open(args.input, 'r', encoding='utf-8') as f:
|
|
plan_data = f.read()
|
|
except Exception as e:
|
|
print(f"Error reading input file: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Initialize planner
|
|
planner = ReleasePlanner()
|
|
planner.min_test_coverage = args.min_coverage
|
|
|
|
try:
|
|
planner.load_release_plan(plan_data)
|
|
except Exception as e:
|
|
print(f"Error loading release plan: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Generate assessment
|
|
assessment = planner.assess_release_readiness()
|
|
|
|
# Generate optional components
|
|
checklist = planner.generate_release_checklist() if args.include_checklist else None
|
|
communication = planner.generate_communication_plan() if args.include_communication else None
|
|
rollback = planner.generate_rollback_runbook() if args.include_rollback else None
|
|
|
|
# Generate output
|
|
if args.output_format == 'json':
|
|
output_data = {
|
|
'assessment': assessment,
|
|
'checklist': checklist,
|
|
'communication_plan': communication,
|
|
'rollback_runbook': rollback
|
|
}
|
|
output_text = json.dumps(output_data, indent=2, default=str)
|
|
|
|
elif args.output_format == 'markdown':
|
|
output_lines = [
|
|
f"# Release Readiness Report - {planner.release_name} v{planner.version}",
|
|
"",
|
|
f"**Overall Status:** {assessment['overall_status'].upper()}",
|
|
f"**Readiness Score:** {assessment['readiness_score']:.1f}%",
|
|
""
|
|
]
|
|
|
|
if assessment['blocking_issues']:
|
|
output_lines.extend([
|
|
"## 🚫 Blocking Issues",
|
|
""
|
|
])
|
|
for issue in assessment['blocking_issues']:
|
|
output_lines.append(f"- {issue}")
|
|
output_lines.append("")
|
|
|
|
if assessment['warnings']:
|
|
output_lines.extend([
|
|
"## ⚠️ Warnings",
|
|
""
|
|
])
|
|
for warning in assessment['warnings']:
|
|
output_lines.append(f"- {warning}")
|
|
output_lines.append("")
|
|
|
|
# Feature summary
|
|
fs = assessment['feature_summary']
|
|
output_lines.extend([
|
|
"## Features Summary",
|
|
"",
|
|
f"- **Total:** {fs['total']}",
|
|
f"- **Ready:** {fs['ready']}",
|
|
f"- **In Progress:** {fs['in_progress']}",
|
|
f"- **Blocked:** {fs['blocked']}",
|
|
f"- **Breaking Changes:** {fs['breaking_changes']}",
|
|
""
|
|
])
|
|
|
|
if checklist:
|
|
output_lines.extend([
|
|
"## Release Checklist",
|
|
""
|
|
])
|
|
current_category = ""
|
|
for item in checklist:
|
|
if item['category'] != current_category:
|
|
current_category = item['category']
|
|
output_lines.append(f"### {current_category}")
|
|
output_lines.append("")
|
|
|
|
status_icon = "✅" if item['status'] == 'ready' else "❌" if item['status'] == 'failed' else "⏳"
|
|
output_lines.append(f"- {status_icon} {item['item']}")
|
|
output_lines.append("")
|
|
|
|
output_text = '\n'.join(output_lines)
|
|
|
|
else: # text format
|
|
output_lines = [
|
|
f"Release Readiness Report",
|
|
f"========================",
|
|
f"Release: {planner.release_name} v{planner.version}",
|
|
f"Status: {assessment['overall_status'].upper()}",
|
|
f"Readiness Score: {assessment['readiness_score']:.1f}%",
|
|
""
|
|
]
|
|
|
|
if assessment['blocking_issues']:
|
|
output_lines.extend(["BLOCKING ISSUES:", ""])
|
|
for issue in assessment['blocking_issues']:
|
|
output_lines.append(f" ❌ {issue}")
|
|
output_lines.append("")
|
|
|
|
if assessment['warnings']:
|
|
output_lines.extend(["WARNINGS:", ""])
|
|
for warning in assessment['warnings']:
|
|
output_lines.append(f" ⚠️ {warning}")
|
|
output_lines.append("")
|
|
|
|
if assessment['recommendations']:
|
|
output_lines.extend(["RECOMMENDATIONS:", ""])
|
|
for rec in assessment['recommendations']:
|
|
output_lines.append(f" 💡 {rec}")
|
|
output_lines.append("")
|
|
|
|
# Summary stats
|
|
fs = assessment['feature_summary']
|
|
gs = assessment['quality_gate_summary']
|
|
|
|
output_lines.extend([
|
|
f"FEATURE SUMMARY:",
|
|
f" Total: {fs['total']} | Ready: {fs['ready']} | Blocked: {fs['blocked']}",
|
|
f" Breaking Changes: {fs['breaking_changes']} | Missing Approvals: {fs['missing_approvals']}",
|
|
"",
|
|
f"QUALITY GATES:",
|
|
f" Total: {gs['total']} | Passed: {gs['passed']} | Failed: {gs['failed']}",
|
|
""
|
|
])
|
|
|
|
output_text = '\n'.join(output_lines)
|
|
|
|
# Write output
|
|
if args.output:
|
|
with open(args.output, 'w', encoding='utf-8') as f:
|
|
f.write(output_text)
|
|
else:
|
|
print(output_text)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |