3508 lines
134 KiB
Python
Executable File
3508 lines
134 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Team Manager - Standardized Team Layout Manager
|
||
|
||
Manages team assignments, tracks phase progress, and validates
|
||
team composition against standardized enterprise layout.
|
||
"""
|
||
|
||
import argparse
|
||
import fcntl
|
||
import gzip
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
import traceback
|
||
from dataclasses import dataclass, asdict
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
import time
|
||
import statistics
|
||
import warnings
|
||
from typing import Any, List, Optional, Dict
|
||
|
||
# FUNC-005: Batch operations support
|
||
try:
|
||
from .batch_operations import (
|
||
import_csv, export_csv, import_json, export_json,
|
||
create_csv_template, create_json_template
|
||
)
|
||
except ImportError:
|
||
from batch_operations import (
|
||
import_csv, export_csv, import_json, export_json,
|
||
create_csv_template, create_json_template
|
||
)
|
||
|
||
# SEC-007: Encryption support
|
||
try:
|
||
from .encryption import EncryptionManager
|
||
except ImportError:
|
||
from encryption import EncryptionManager
|
||
|
||
|
||
|
||
class StructuredLogger:
|
||
"""JSON structured logging with correlation ID support."""
|
||
|
||
def __init__(self, component: str, request_id: Optional[str] = None):
|
||
self.component = component
|
||
self.request_id = request_id
|
||
|
||
def log(self, event_type: str, details: Dict, level: str = "info") -> None:
|
||
"""Log a structured JSON event."""
|
||
log_entry = {
|
||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||
"level": level.upper(),
|
||
"component": self.component,
|
||
"event": event_type,
|
||
"details": details
|
||
}
|
||
if self.request_id:
|
||
log_entry["request_id"] = self.request_id
|
||
print(json.dumps(log_entry), file=sys.stderr)
|
||
|
||
def info(self, event_type: str, details: Dict) -> None:
|
||
"""Log INFO level event."""
|
||
self.log(event_type, details, "info")
|
||
|
||
def warn(self, event_type: str, details: Dict) -> None:
|
||
"""Log WARN level event."""
|
||
self.log(event_type, details, "warn")
|
||
|
||
def error(self, event_type: str, details: Dict, exc_info: bool = False) -> None:
|
||
"""Log ERROR level event with optional exception info."""
|
||
if exc_info:
|
||
details = {**details, "stack_trace": traceback.format_exc()}
|
||
self.log(event_type, details, "error")
|
||
|
||
def debug(self, event_type: str, details: Dict) -> None:
|
||
"""Log DEBUG level event."""
|
||
self.log(event_type, details, "debug")
|
||
|
||
class RulesLoader:
|
||
"""Loads and manages rules from JSON configuration file (FUNC-008).
|
||
|
||
Provides dynamic loading of validation rules, team size limits,
|
||
phase gates, and other configurable settings.
|
||
"""
|
||
|
||
DEFAULT_RULES_PATH = Path(".teams/rules.json")
|
||
|
||
def __init__(self, rules_path: Path = None):
|
||
self.rules_path = rules_path or self.DEFAULT_RULES_PATH
|
||
self._rules: Dict[str, Any] = {}
|
||
self._load_rules()
|
||
|
||
def _load_rules(self) -> None:
|
||
"""Load rules from JSON file or use defaults."""
|
||
if self.rules_path.exists():
|
||
try:
|
||
with open(self.rules_path, 'r') as f:
|
||
self._rules = json.load(f)
|
||
except (json.JSONDecodeError, IOError) as e:
|
||
print(f"⚠️ Failed to load rules from {self.rules_path}: {e}", file=sys.stderr)
|
||
self._rules = self._get_default_rules()
|
||
else:
|
||
self._rules = self._get_default_rules()
|
||
|
||
def _get_default_rules(self) -> Dict[str, Any]:
|
||
"""Return default rules when rules.json is not available."""
|
||
return {
|
||
"team_size_limits": {"min": 4, "max": 6},
|
||
"duplicate_detection": {"enabled": True, "scope": "project", "action": "warn"},
|
||
"phase_gates": {},
|
||
"allowed_agent_types": ["planner", "coder", "reviewer", "security", "tester", "ops"],
|
||
"validation_rules": {
|
||
"person_name": {
|
||
"max_length": 256,
|
||
"email_pattern": r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||
"username_pattern": r'^[a-zA-Z0-9_.-]+$'
|
||
},
|
||
"role_name": {"max_length": 128},
|
||
"project_name": {"max_length": 64, "pattern": r'^[a-zA-Z0-9_-]+$'}
|
||
}
|
||
}
|
||
|
||
def reload_rules(self) -> None:
|
||
"""Reload rules from disk."""
|
||
self._load_rules()
|
||
|
||
def get(self, key: str, default: Any = None) -> Any:
|
||
"""Get a rule by key path (e.g., 'team_size_limits.min')."""
|
||
keys = key.split('.')
|
||
value = self._rules
|
||
for k in keys:
|
||
if isinstance(value, dict) and k in value:
|
||
value = value[k]
|
||
else:
|
||
return default
|
||
return value
|
||
|
||
def get_team_size_limits(self) -> tuple:
|
||
"""Get min and max team size limits."""
|
||
limits = self._rules.get("team_size_limits", {})
|
||
return (limits.get("min", 4), limits.get("max", 6))
|
||
|
||
def get_duplicate_detection_config(self) -> Dict[str, Any]:
|
||
"""Get duplicate detection configuration."""
|
||
return self._rules.get("duplicate_detection", {
|
||
"enabled": True,
|
||
"scope": "project",
|
||
"action": "warn"
|
||
})
|
||
|
||
def get_validation_pattern(self, rule_type: str, pattern_name: str) -> Optional[str]:
|
||
"""Get a validation regex pattern."""
|
||
rules = self._rules.get("validation_rules", {})
|
||
if rule_type in rules:
|
||
return rules[rule_type].get(pattern_name)
|
||
return None
|
||
|
||
@property
|
||
def rules(self) -> Dict[str, Any]:
|
||
"""Return all loaded rules."""
|
||
return self._rules
|
||
|
||
|
||
class EncryptionManager:
|
||
"""Optional encryption at rest for sensitive data (SEC-007).
|
||
|
||
Uses Fernet symmetric encryption when TEAM_ENCRYPTION_KEY env var is set.
|
||
Encrypts sensitive fields while keeping structure readable.
|
||
"""
|
||
|
||
def __init__(self):
|
||
self._key = None
|
||
self._fernet = None
|
||
self._enabled = False
|
||
self._init_encryption()
|
||
|
||
def _init_encryption(self) -> None:
|
||
"""Initialize encryption from environment key."""
|
||
import base64
|
||
import os
|
||
|
||
key = os.environ.get("TEAM_ENCRYPTION_KEY")
|
||
if key:
|
||
try:
|
||
# Ensure key is proper Fernet key (32 bytes, base64 encoded)
|
||
if len(key) == 44: # Base64 encoded 32 bytes
|
||
from cryptography.fernet import Fernet
|
||
self._key = key.encode()
|
||
self._fernet = Fernet(self._key)
|
||
self._enabled = True
|
||
else:
|
||
# Derive key from provided string
|
||
import hashlib
|
||
derived = hashlib.sha256(key.encode()).digest()
|
||
from cryptography.fernet import Fernet
|
||
self._key = base64.urlsafe_b64encode(derived)
|
||
self._fernet = Fernet(self._key)
|
||
self._enabled = True
|
||
except ImportError:
|
||
print("⚠️ cryptography library not installed. Encryption disabled.", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"⚠️ Failed to initialize encryption: {e}", file=sys.stderr)
|
||
|
||
@property
|
||
def enabled(self) -> bool:
|
||
"""Check if encryption is enabled."""
|
||
return self._enabled
|
||
|
||
def encrypt(self, data: str) -> str:
|
||
"""Encrypt a string value."""
|
||
if not self._enabled or not data:
|
||
return data
|
||
try:
|
||
return self._fernet.encrypt(data.encode()).decode()
|
||
except Exception:
|
||
return data
|
||
|
||
def decrypt(self, data: str) -> str:
|
||
"""Decrypt an encrypted value."""
|
||
if not self._enabled or not data:
|
||
return data
|
||
try:
|
||
return self._fernet.decrypt(data.encode()).decode()
|
||
except Exception:
|
||
return data # Return as-is if decryption fails
|
||
|
||
def encrypt_dict(self, data: Dict, sensitive_fields: List[str]) -> Dict:
|
||
"""Encrypt sensitive fields in a dictionary."""
|
||
if not self._enabled:
|
||
return data
|
||
|
||
result = data.copy()
|
||
for field in sensitive_fields:
|
||
if field in result and isinstance(result[field], str):
|
||
result[field] = self.encrypt(result[field])
|
||
return result
|
||
|
||
def decrypt_dict(self, data: Dict, sensitive_fields: List[str]) -> Dict:
|
||
"""Decrypt sensitive fields in a dictionary."""
|
||
if not self._enabled:
|
||
return data
|
||
|
||
result = data.copy()
|
||
for field in sensitive_fields:
|
||
if field in result and isinstance(result[field], str):
|
||
result[field] = self.decrypt(result[field])
|
||
return result
|
||
|
||
|
||
class MigrationManager:
|
||
"""Manages data migrations between versions (OPS-006).
|
||
|
||
Provides automatic migration detection and execution for
|
||
project data files when schema versions change.
|
||
"""
|
||
|
||
CURRENT_VERSION = "1.0.0"
|
||
MIGRATIONS_DIR = Path("scripts/migrations")
|
||
|
||
def __init__(self, project_name: str):
|
||
self.project_name = project_name
|
||
self.config_path = Path(f".teams/{project_name}.json")
|
||
self.migrations: Dict[str, callable] = {}
|
||
self._register_migrations()
|
||
|
||
def _register_migrations(self) -> None:
|
||
"""Register available migration scripts."""
|
||
# Register migrations from version -> version
|
||
# Each migration should be a callable that takes data dict and returns migrated data
|
||
self.migrations = {
|
||
# Example: "0.9.0": self._migrate_v090_to_v100,
|
||
}
|
||
|
||
def get_data_version(self, data: Dict[str, Any]) -> str:
|
||
"""Extract version from data dict."""
|
||
return data.get("version", "1.0.0")
|
||
|
||
def needs_migration(self, data: Dict[str, Any]) -> bool:
|
||
"""Check if data needs migration."""
|
||
data_version = self.get_data_version(data)
|
||
return self._version_compare(data_version, self.CURRENT_VERSION) < 0
|
||
|
||
def _version_compare(self, v1: str, v2: str) -> int:
|
||
"""Compare two version strings. Returns -1, 0, or 1."""
|
||
def parse_version(v):
|
||
parts = v.split('.')
|
||
return [int(p) for p in parts]
|
||
|
||
p1 = parse_version(v1)
|
||
p2 = parse_version(v2)
|
||
|
||
for i in range(max(len(p1), len(p2))):
|
||
n1 = p1[i] if i < len(p1) else 0
|
||
n2 = p2[i] if i < len(p2) else 0
|
||
if n1 < n2:
|
||
return -1
|
||
elif n1 > n2:
|
||
return 1
|
||
return 0
|
||
|
||
def migrate(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Migrate data to current version.
|
||
|
||
Args:
|
||
data: The data dict to migrate
|
||
|
||
Returns:
|
||
Migrated data dict with updated version
|
||
"""
|
||
original_version = self.get_data_version(data)
|
||
current_version = original_version
|
||
|
||
if not self.needs_migration(data):
|
||
return data
|
||
|
||
print(f"🔄 Migrating project '{self.project_name}' from v{original_version} to v{self.CURRENT_VERSION}")
|
||
|
||
# Apply migrations in order
|
||
for target_version, migration_func in sorted(
|
||
self.migrations.items(),
|
||
key=lambda x: self._version_compare(x[0], self.CURRENT_VERSION)
|
||
):
|
||
if self._version_compare(current_version, target_version) < 0:
|
||
print(f" Applying migration to v{target_version}...")
|
||
try:
|
||
data = migration_func(data)
|
||
data["version"] = target_version
|
||
current_version = target_version
|
||
except Exception as e:
|
||
print(f" ❌ Migration failed: {e}")
|
||
raise
|
||
|
||
# Update to final version
|
||
data["version"] = self.CURRENT_VERSION
|
||
data["migrated_from"] = original_version
|
||
data["migrated_at"] = datetime.now().isoformat()
|
||
|
||
print(f"✅ Migration complete: v{original_version} -> v{self.CURRENT_VERSION}")
|
||
return data
|
||
|
||
def get_migration_status(self) -> Dict[str, Any]:
|
||
"""Get migration status for project."""
|
||
if not self.config_path.exists():
|
||
return {"status": "not_found", "current_version": None}
|
||
|
||
try:
|
||
with open(self.config_path, 'r') as f:
|
||
data = json.load(f)
|
||
data_version = self.get_data_version(data)
|
||
needs_mig = self._version_compare(data_version, self.CURRENT_VERSION) < 0
|
||
|
||
return {
|
||
"status": "needs_migration" if needs_mig else "current",
|
||
"current_version": data_version,
|
||
"target_version": self.CURRENT_VERSION,
|
||
"project": self.project_name
|
||
}
|
||
except Exception as e:
|
||
return {"status": "error", "error": str(e)}
|
||
|
||
|
||
return self._rules.copy()
|
||
|
||
|
||
# Global rules loader instance (initialized on first use)
|
||
_rules_loader: Optional[RulesLoader] = None
|
||
|
||
|
||
def get_rules_loader() -> RulesLoader:
|
||
"""Get or create the global rules loader instance."""
|
||
global _rules_loader
|
||
if _rules_loader is None:
|
||
_rules_loader = RulesLoader()
|
||
return _rules_loader
|
||
|
||
|
||
def reload_rules_cmd() -> None:
|
||
"""Reload rules from JSON file (FUNC-008) - CLI command."""
|
||
global _rules_loader
|
||
if _rules_loader is None:
|
||
_rules_loader = RulesLoader()
|
||
else:
|
||
_rules_loader.reload_rules()
|
||
print(f"✅ Rules reloaded from {RulesLoader.DEFAULT_RULES_PATH}")
|
||
|
||
class PerformanceMetrics:
|
||
"""Performance metrics collector for team operations (OPS-008).
|
||
|
||
Tracks operation duration (init, assign, start, complete).
|
||
Stores metrics in .teams/metrics.json for analysis.
|
||
"""
|
||
|
||
def __init__(self, project_name: str, metrics_dir: Path = None):
|
||
self.project_name = project_name
|
||
self.metrics_dir = metrics_dir or Path(".teams")
|
||
self.metrics_file = self.metrics_dir / "metrics.json"
|
||
self.metrics_dir.mkdir(parents=True, exist_ok=True)
|
||
self._current_operations: Dict[str, float] = {}
|
||
|
||
def start_operation(self, operation: str, **context) -> None:
|
||
"""Start timing an operation."""
|
||
start_time = time.time()
|
||
self._current_operations[operation] = start_time
|
||
log_entry = {
|
||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||
"level": "INFO",
|
||
"component": "performance_metrics",
|
||
"event": "operation_started",
|
||
"details": {"operation": operation, "project": self.project_name, **context}
|
||
}
|
||
print(json.dumps(log_entry), file=sys.stderr)
|
||
|
||
def end_operation(self, operation: str, success: bool = True,
|
||
error_type: Optional[str] = None, **context) -> Dict[str, Any]:
|
||
"""End timing an operation and record metrics."""
|
||
end_time = time.time()
|
||
start_time = self._current_operations.pop(operation, end_time)
|
||
duration_ms = (end_time - start_time) * 1000
|
||
metric_entry = {
|
||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||
"project": self.project_name,
|
||
"operation": operation,
|
||
"duration_ms": round(duration_ms, 2),
|
||
"success": success,
|
||
"context": context
|
||
}
|
||
if error_type:
|
||
metric_entry["error_type"] = error_type
|
||
self._append_metric(metric_entry)
|
||
log_entry = {
|
||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||
"level": "INFO" if success else "ERROR",
|
||
"component": "performance_metrics",
|
||
"event": "operation_completed",
|
||
"details": metric_entry
|
||
}
|
||
print(json.dumps(log_entry), file=sys.stderr)
|
||
return metric_entry
|
||
|
||
def _append_metric(self, metric: Dict[str, Any]) -> None:
|
||
"""Append a metric entry to the metrics file."""
|
||
try:
|
||
with open(self.metrics_file, 'a') as f:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||
try:
|
||
f.write(json.dumps(metric) + "\n")
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
finally:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||
except Exception as e:
|
||
print(f"⚠️ Failed to write metric: {e}", file=sys.stderr)
|
||
|
||
def load_metrics(self, since: Optional[datetime] = None,
|
||
operation: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""Load metrics from file with optional filtering."""
|
||
if not self.metrics_file.exists():
|
||
return []
|
||
metrics = []
|
||
try:
|
||
with open(self.metrics_file, 'r') as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
entry = json.loads(line)
|
||
if since:
|
||
entry_time = datetime.fromisoformat(entry["timestamp"].replace("Z", "+00:00"))
|
||
if entry_time < since:
|
||
continue
|
||
if operation and entry.get("operation") != operation:
|
||
continue
|
||
metrics.append(entry)
|
||
except (json.JSONDecodeError, KeyError):
|
||
continue
|
||
except Exception as e:
|
||
print(f"⚠️ Failed to load metrics: {e}", file=sys.stderr)
|
||
return metrics
|
||
|
||
def get_operation_stats(self, operation: Optional[str] = None,
|
||
since: Optional[datetime] = None) -> Dict[str, Any]:
|
||
"""Get statistics for operations."""
|
||
metrics = self.load_metrics(since=since, operation=operation)
|
||
if not metrics:
|
||
return {"operation": operation or "all", "count": 0, "message": "No metrics found"}
|
||
durations = [m["duration_ms"] for m in metrics if "duration_ms" in m]
|
||
successes = [m for m in metrics if m.get("success", True)]
|
||
failures = [m for m in metrics if not m.get("success", True)]
|
||
stats = {
|
||
"operation": operation or "all",
|
||
"count": len(metrics),
|
||
"success_count": len(successes),
|
||
"failure_count": len(failures),
|
||
"success_rate": round(len(successes) / len(metrics) * 100, 2) if metrics else 0,
|
||
"error_rate": round(len(failures) / len(metrics) * 100, 2) if metrics else 0
|
||
}
|
||
if durations:
|
||
stats["duration_stats"] = {
|
||
"avg_ms": round(statistics.mean(durations), 2),
|
||
"min_ms": round(min(durations), 2),
|
||
"max_ms": round(max(durations), 2),
|
||
"median_ms": round(statistics.median(durations), 2),
|
||
}
|
||
if len(durations) > 1:
|
||
stats["duration_stats"]["stdev_ms"] = round(statistics.stdev(durations), 2)
|
||
sorted_by_duration = sorted(metrics, key=lambda m: m.get("duration_ms", 0), reverse=True)
|
||
stats["slowest_operations"] = [
|
||
{"operation": m["operation"], "duration_ms": m["duration_ms"],
|
||
"timestamp": m["timestamp"], "context": m.get("context", {})}
|
||
for m in sorted_by_duration[:5]
|
||
]
|
||
return stats
|
||
|
||
def get_report(self, days: int = 7) -> Dict[str, Any]:
|
||
"""Generate a performance report."""
|
||
since = datetime.utcnow() - timedelta(days=days)
|
||
all_metrics = self.load_metrics(since=since)
|
||
report = {
|
||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||
"project": self.project_name,
|
||
"time_window_days": days,
|
||
"since": since.isoformat() + "Z",
|
||
"overall": self.get_operation_stats(since=since),
|
||
"by_operation": {}
|
||
}
|
||
operations = set(m.get("operation", "unknown") for m in all_metrics)
|
||
for op in operations:
|
||
report["by_operation"][op] = self.get_operation_stats(operation=op, since=since)
|
||
return report
|
||
|
||
def export_report(self, output_path: Path, format: str = "json", days: int = 7) -> bool:
|
||
"""Export performance report to file."""
|
||
report = self.get_report(days=days)
|
||
try:
|
||
if format == "json":
|
||
with open(output_path, 'w') as f:
|
||
json.dump(report, f, indent=2)
|
||
elif format == "csv":
|
||
since = datetime.utcnow() - timedelta(days=days)
|
||
metrics = self.load_metrics(since=since)
|
||
with open(output_path, 'w', newline='') as f:
|
||
if metrics:
|
||
writer = csv.DictWriter(f, fieldnames=["timestamp", "project", "operation", "duration_ms", "success", "context"])
|
||
writer.writeheader()
|
||
for m in metrics:
|
||
m_flat = {k: v for k, v in m.items() if k != "error_type"}
|
||
m_flat["context"] = json.dumps(m_flat.get("context", {}))
|
||
writer.writerow(m_flat)
|
||
return True
|
||
except Exception as e:
|
||
print(f"❌ Export failed: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
|
||
|
||
|
||
def validate_project_name(name: str) -> None:
|
||
"""Validate project name to prevent command injection."""
|
||
loader = get_rules_loader()
|
||
rules = loader.get("validation_rules.project_name", {})
|
||
max_len = rules.get("max_length", 64)
|
||
pattern = rules.get("pattern", r'^[a-zA-Z0-9_-]+$')
|
||
|
||
if not name:
|
||
raise ValueError("project_name is required")
|
||
if len(name) > max_len:
|
||
raise ValueError(f"project_name must be {max_len} characters or less")
|
||
if not re.match(pattern, name):
|
||
raise ValueError("project_name must contain only letters, numbers, hyphens, and underscores")
|
||
|
||
|
||
def validate_project_path(project_name: str, base_dir: str = ".teams") -> Path:
|
||
"""Validate project path to prevent path traversal attacks (SEC-006).
|
||
|
||
Args:
|
||
project_name: The project name to validate
|
||
base_dir: The base directory for projects (default: .teams)
|
||
|
||
Returns:
|
||
Path: The validated and resolved path
|
||
|
||
Raises:
|
||
SecurityError: If path traversal is detected
|
||
"""
|
||
# First validate the project name format
|
||
validate_project_name(project_name)
|
||
|
||
# Check for path traversal patterns in the raw input
|
||
dangerous_patterns = ['..', '/', '\\', '\x00']
|
||
for pattern in dangerous_patterns:
|
||
if pattern in project_name:
|
||
raise SecurityError(
|
||
f"Path traversal detected: project_name contains forbidden pattern '{pattern}'"
|
||
)
|
||
|
||
# Construct the intended path
|
||
base_path = Path(base_dir).resolve()
|
||
intended_path = base_path / f"{project_name}.json"
|
||
|
||
# Resolve any symlinks and get the real path
|
||
try:
|
||
if intended_path.exists():
|
||
real_path = Path(os.path.realpath(intended_path))
|
||
else:
|
||
# For non-existent paths, resolve the parent and join the filename
|
||
real_parent = Path(os.path.realpath(base_path))
|
||
real_path = real_parent / f"{project_name}.json"
|
||
except (OSError, ValueError) as e:
|
||
raise SecurityError(f"Path resolution failed: {e}")
|
||
|
||
# Ensure the resolved path is within the base directory
|
||
try:
|
||
real_path.relative_to(base_path)
|
||
except ValueError:
|
||
raise SecurityError(
|
||
f"Path traversal detected: resolved path '{real_path}' is outside base directory '{base_path}'"
|
||
)
|
||
|
||
return real_path
|
||
|
||
|
||
# Valid phases from TEAM_STRUCTURE.md
|
||
VALID_PHASES = {
|
||
"Phase 1: Strategy, Governance & Planning",
|
||
"Phase 2: Platform & Foundation",
|
||
"Phase 3: The Build Squads",
|
||
"Phase 4: Validation & Hardening",
|
||
"Phase 5: Delivery & Sustainment",
|
||
}
|
||
|
||
# Valid role names from TEAM_STRUCTURE.md (48 roles across 12 teams)
|
||
VALID_ROLES = {
|
||
# Team 1: Business & Product Strategy
|
||
"Business Relationship Manager",
|
||
"Lead Product Manager",
|
||
"Business Systems Analyst",
|
||
"Financial Controller (FinOps)",
|
||
# Team 2: Enterprise Architecture
|
||
"Chief Architect",
|
||
"Domain Architect",
|
||
"Solution Architect",
|
||
"Standards Lead",
|
||
# Team 3: GRC
|
||
"Compliance Officer",
|
||
"Internal Auditor",
|
||
"Privacy Engineer",
|
||
"Policy Manager",
|
||
# Team 4: Infrastructure & Cloud Ops
|
||
"Cloud Architect",
|
||
"IaC Engineer",
|
||
"Network Security Engineer",
|
||
"Storage Engineer",
|
||
# Team 5: Platform Engineering
|
||
"Platform Product Manager",
|
||
"CI/CD Architect",
|
||
"Kubernetes Administrator",
|
||
"Developer Advocate",
|
||
# Team 6: Data Governance & Analytics
|
||
"Data Architect",
|
||
"DBA",
|
||
"Data Privacy Officer",
|
||
"ETL Developer",
|
||
# Team 7: Core Feature Squad
|
||
"Technical Lead",
|
||
"Senior Backend Engineer",
|
||
"Senior Frontend Engineer",
|
||
"Accessibility (A11y) Expert",
|
||
"Technical Writer",
|
||
# Team 8: Middleware & Integration
|
||
"API Product Manager",
|
||
"Integration Engineer",
|
||
"Messaging Engineer",
|
||
"IAM Specialist",
|
||
# Team 9: Cybersecurity
|
||
"Security Architect",
|
||
"Vulnerability Researcher",
|
||
"Penetration Tester",
|
||
"DevSecOps Engineer",
|
||
# Team 10: Quality Engineering
|
||
"QA Architect",
|
||
"SDET",
|
||
"Performance/Load Engineer",
|
||
"Manual QA / UAT Coordinator",
|
||
# Team 11: SRE
|
||
"SRE Lead",
|
||
"Observability Engineer",
|
||
"Chaos Engineer",
|
||
"Incident Manager",
|
||
# Team 12: IT Operations & Support
|
||
"NOC Analyst",
|
||
"Change Manager",
|
||
"Release Manager",
|
||
"L3 Support Engineer",
|
||
}
|
||
|
||
|
||
def get_valid_phases() -> set:
|
||
"""Get valid phases from rules or defaults."""
|
||
loader = get_rules_loader()
|
||
phase_gates = loader.get("phase_gates", {})
|
||
if phase_gates:
|
||
return set(phase_gates.keys())
|
||
# Default phases if not configured
|
||
return {
|
||
"Phase 1: Strategy, Governance & Planning",
|
||
"Phase 2: Platform & Foundation",
|
||
"Phase 3: The Build Squads",
|
||
"Phase 4: Validation & Hardening",
|
||
"Phase 5: Delivery & Sustainment",
|
||
}
|
||
|
||
|
||
def validate_phase(phase: str) -> None:
|
||
"""Validate phase name against valid phases.
|
||
|
||
Args:
|
||
phase: Phase name to validate
|
||
|
||
Raises:
|
||
ValueError: If phase is not a valid phase name
|
||
"""
|
||
valid_phases = get_valid_phases()
|
||
if not phase:
|
||
raise ValueError("phase is required")
|
||
if phase not in valid_phases:
|
||
raise ValueError(
|
||
f"Invalid phase: '{phase}'. Must be one of: "
|
||
f"{', '.join(sorted(valid_phases))}"
|
||
)
|
||
|
||
|
||
def validate_role_name(role_name: str) -> None:
|
||
"""Validate role name against valid roles from TEAM_STRUCTURE.md.
|
||
|
||
Args:
|
||
role_name: Role name to validate
|
||
|
||
Raises:
|
||
ValueError: If role_name is not a valid role
|
||
"""
|
||
if not role_name:
|
||
raise ValueError("role_name is required")
|
||
if len(role_name) > 128:
|
||
raise ValueError("role_name must be 128 characters or less")
|
||
# Check for control characters
|
||
if re.search(r'[\x00-\x1f\x7f]', role_name):
|
||
raise ValueError("role_name contains invalid control characters")
|
||
if role_name not in VALID_ROLES:
|
||
raise ValueError(
|
||
f"Invalid role_name: '{role_name}'. Must be one of the 48 defined roles. "
|
||
f"See TEAM_STRUCTURE.md for valid role definitions."
|
||
)
|
||
|
||
|
||
def validate_person_name(person: str) -> None:
|
||
"""Validate person/assignee name format.
|
||
|
||
Accepts email addresses, usernames, or display names with alphanumeric
|
||
characters, spaces, hyphens, underscores, dots, and apostrophes.
|
||
|
||
Args:
|
||
person: Person name/identifier to validate
|
||
|
||
Raises:
|
||
ValueError: If person format is invalid
|
||
"""
|
||
loader = get_rules_loader()
|
||
rules = loader.get("validation_rules.person_name", {})
|
||
max_len = rules.get("max_length", 256)
|
||
email_pattern = rules.get("email_pattern", r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||
username_pattern = rules.get("username_pattern", r'^[a-zA-Z0-9_.-]+$')
|
||
# Display names allow spaces and apostrophes (e.g., "Alice Smith", "O'Connor")
|
||
display_name_pattern = r'^[a-zA-Z0-9_.\-\' ]+$'
|
||
|
||
if not person:
|
||
raise ValueError("person is required")
|
||
if len(person) > max_len:
|
||
raise ValueError(f"person must be {max_len} characters or less")
|
||
# Check for control characters
|
||
if re.search(r'[\x00-\x1f\x7f]', person):
|
||
raise ValueError("person contains invalid control characters")
|
||
# Check for dangerous patterns
|
||
dangerous_patterns = [";", "|", "&&", "||", "`", "$", "<", ">", "..", "\\"]
|
||
for pattern in dangerous_patterns:
|
||
if pattern in person:
|
||
raise ValueError(f"person contains forbidden pattern: {pattern}")
|
||
# Allow email format, username format, or display name format
|
||
if not re.match(email_pattern, person) and not re.match(username_pattern, person) and not re.match(display_name_pattern, person):
|
||
raise ValueError(
|
||
f"Invalid person format: '{person}'. "
|
||
f"Must be a valid email address, username, or display name"
|
||
)
|
||
|
||
|
||
class PermissionDenied(Exception):
|
||
"""Raised when user lacks permission for an operation."""
|
||
pass
|
||
|
||
|
||
class FileLockError(Exception):
|
||
"""Raised when file locking fails."""
|
||
pass
|
||
|
||
|
||
class SecurityError(Exception):
|
||
"""Raised when a security violation is detected."""
|
||
pass
|
||
|
||
|
||
class RateLimitExceeded(Exception):
|
||
"""Raised when rate limit is exceeded."""
|
||
def __init__(self, message: str, retry_after: int = None):
|
||
super().__init__(message)
|
||
self.retry_after = retry_after
|
||
|
||
|
||
class RateLimiter:
|
||
"""Token bucket rate limiter for API requests (SEC-005).
|
||
|
||
Implements per-user rate limiting with configurable limits.
|
||
Stores state in memory with automatic cleanup.
|
||
"""
|
||
|
||
DEFAULT_REQUESTS = 100
|
||
DEFAULT_WINDOW = 60 # seconds
|
||
CLEANUP_INTERVAL = 300 # cleanup every 5 minutes
|
||
|
||
def __init__(self, config_path: Path = None):
|
||
self.config_path = config_path or Path(".teams/config.json")
|
||
self._buckets: Dict[str, Dict] = {}
|
||
self._last_cleanup = time.time()
|
||
self._load_config()
|
||
|
||
def _load_config(self) -> None:
|
||
"""Load rate limit configuration from config file."""
|
||
self.enabled = True
|
||
self.requests_per_window = self.DEFAULT_REQUESTS
|
||
self.window_seconds = self.DEFAULT_WINDOW
|
||
|
||
if self.config_path.exists():
|
||
try:
|
||
with open(self.config_path, 'r') as f:
|
||
config = json.load(f)
|
||
rate_config = config.get("rate_limiting", {})
|
||
self.enabled = rate_config.get("enabled", True)
|
||
self.requests_per_window = rate_config.get("requests_per_window", self.DEFAULT_REQUESTS)
|
||
self.window_seconds = rate_config.get("window_seconds", self.DEFAULT_WINDOW)
|
||
except (json.JSONDecodeError, IOError):
|
||
pass # Use defaults
|
||
|
||
def _cleanup_old_buckets(self) -> None:
|
||
"""Remove expired buckets to prevent memory leaks."""
|
||
now = time.time()
|
||
if now - self._last_cleanup < self.CLEANUP_INTERVAL:
|
||
return
|
||
|
||
expired = []
|
||
for user_id, bucket in self._buckets.items():
|
||
if now - bucket.get("last_reset", 0) > self.window_seconds * 2:
|
||
expired.append(user_id)
|
||
|
||
for user_id in expired:
|
||
del self._buckets[user_id]
|
||
|
||
self._last_cleanup = now
|
||
|
||
def check_rate_limit(self, user_id: str = "default") -> tuple[bool, dict]:
|
||
"""Check if request is within rate limit.
|
||
|
||
Args:
|
||
user_id: Unique identifier for the user (default: "default")
|
||
|
||
Returns:
|
||
Tuple of (allowed, rate_limit_info)
|
||
rate_limit_info contains: limit, remaining, reset_time
|
||
"""
|
||
if not self.enabled:
|
||
return True, {"limit": -1, "remaining": -1, "reset_time": 0}
|
||
|
||
self._cleanup_old_buckets()
|
||
|
||
now = time.time()
|
||
|
||
if user_id not in self._buckets:
|
||
self._buckets[user_id] = {
|
||
"tokens": self.requests_per_window,
|
||
"last_reset": now
|
||
}
|
||
|
||
bucket = self._buckets[user_id]
|
||
|
||
# Check if window has passed and reset
|
||
if now - bucket["last_reset"] >= self.window_seconds:
|
||
bucket["tokens"] = self.requests_per_window
|
||
bucket["last_reset"] = now
|
||
|
||
# Calculate remaining time until reset
|
||
reset_time = int(bucket["last_reset"] + self.window_seconds)
|
||
remaining = max(0, bucket["tokens"] - 1)
|
||
|
||
rate_limit_info = {
|
||
"limit": self.requests_per_window,
|
||
"remaining": remaining,
|
||
"reset_time": reset_time
|
||
}
|
||
|
||
# Check if token available
|
||
if bucket["tokens"] <= 0:
|
||
return False, rate_limit_info
|
||
|
||
# Consume token
|
||
bucket["tokens"] -= 1
|
||
return True, rate_limit_info
|
||
|
||
def get_rate_limit_headers(self, user_id: str = "default") -> Dict[str, str]:
|
||
"""Get rate limit headers for response.
|
||
|
||
Args:
|
||
user_id: Unique identifier for the user
|
||
|
||
Returns:
|
||
Dict of HTTP headers for rate limiting
|
||
"""
|
||
if not self.enabled or user_id not in self._buckets:
|
||
return {}
|
||
|
||
bucket = self._buckets[user_id]
|
||
remaining = max(0, bucket["tokens"])
|
||
reset_time = int(bucket["last_reset"] + self.window_seconds)
|
||
|
||
return {
|
||
"X-RateLimit-Limit": str(self.requests_per_window),
|
||
"X-RateLimit-Remaining": str(remaining),
|
||
"X-RateLimit-Reset": str(reset_time)
|
||
}
|
||
|
||
|
||
class BackupManager:
|
||
"""Manages automatic backups of team configurations.
|
||
|
||
Implements OPS-004: Automated backup before writes with versioning.
|
||
Keeps last N versions and stores in .teams/backups/
|
||
"""
|
||
|
||
DEFAULT_MAX_BACKUPS = 10
|
||
|
||
def __init__(self, project_name: str, backup_dir: Path = None, max_backups: int = None):
|
||
self.project_name = project_name
|
||
self.backup_dir = backup_dir or Path(".teams/backups")
|
||
self.max_backups = max_backups or self.DEFAULT_MAX_BACKUPS
|
||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _get_backup_path(self, timestamp: str = None) -> Path:
|
||
"""Generate backup file path with timestamp."""
|
||
ts = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
# SEC-006: Sanitize project name for filename to prevent path traversal
|
||
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', self.project_name)
|
||
return self.backup_dir / f"{safe_name}_{ts}.json.gz"
|
||
|
||
def create_backup(self, config_path: Path) -> Optional[Path]:
|
||
"""Create a backup of the current configuration.
|
||
|
||
Args:
|
||
config_path: Path to the configuration file to backup
|
||
|
||
Returns:
|
||
Path to the backup file, or None if no file exists to backup
|
||
"""
|
||
if not config_path.exists():
|
||
return None
|
||
|
||
# Generate timestamp for this backup
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||
backup_path = self._get_backup_path(timestamp)
|
||
|
||
# Copy and compress the file
|
||
try:
|
||
import gzip
|
||
with open(config_path, 'rb') as src:
|
||
with gzip.open(backup_path, 'wb') as dst:
|
||
dst.write(src.read())
|
||
|
||
# Clean up old backups
|
||
self._cleanup_old_backups()
|
||
|
||
return backup_path
|
||
except Exception as e:
|
||
# If backup fails, log but don't block the save
|
||
print(f"⚠️ Backup creation failed: {e}", file=sys.stderr)
|
||
return None
|
||
|
||
def _cleanup_old_backups(self) -> None:
|
||
"""Remove oldest backups keeping only max_backups versions."""
|
||
try:
|
||
backups = sorted(
|
||
self.backup_dir.glob(f"{self.project_name}_*.json.gz"),
|
||
key=lambda p: p.stat().st_mtime
|
||
)
|
||
|
||
while len(backups) > self.max_backups:
|
||
oldest = backups.pop(0)
|
||
try:
|
||
oldest.unlink()
|
||
print(f"🗑️ Removed old backup: {oldest.name}", file=sys.stderr)
|
||
except OSError:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
def list_backups(self) -> List[Dict[str, Any]]:
|
||
"""List all available backups for this project.
|
||
|
||
Returns:
|
||
List of dicts with backup info: path, timestamp, size
|
||
"""
|
||
backups = []
|
||
for backup_file in sorted(self.backup_dir.glob(f"{self.project_name}_*.json.gz"), reverse=True):
|
||
try:
|
||
stat = backup_file.stat()
|
||
# Extract timestamp from filename
|
||
timestamp_str = backup_file.stem.replace(f"{self.project_name}_", "")
|
||
backups.append({
|
||
"path": str(backup_file),
|
||
"filename": backup_file.name,
|
||
"timestamp": timestamp_str,
|
||
"size_bytes": stat.st_size,
|
||
"created_at": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||
})
|
||
except OSError:
|
||
continue
|
||
return backups
|
||
|
||
def restore_backup(self, backup_path: Path, target_path: Path) -> bool:
|
||
"""Restore a backup to the target path.
|
||
|
||
Args:
|
||
backup_path: Path to the backup file
|
||
target_path: Path to restore to
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
try:
|
||
import gzip
|
||
with gzip.open(backup_path, 'rb') as src:
|
||
content = src.read()
|
||
|
||
# Atomic restore: write to temp then rename
|
||
fd, temp_path = tempfile.mkstemp(
|
||
dir=target_path.parent,
|
||
prefix=f".{self.project_name}.restore.tmp."
|
||
)
|
||
try:
|
||
with os.fdopen(fd, 'wb') as f:
|
||
f.write(content)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
os.replace(temp_path, target_path)
|
||
return True
|
||
except Exception:
|
||
try:
|
||
os.unlink(temp_path)
|
||
except FileNotFoundError:
|
||
pass
|
||
raise
|
||
except Exception as e:
|
||
print(f"❌ Restore failed: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
|
||
class AuditLogger:
|
||
"""Audit logging for team operations (SEC-008).
|
||
|
||
Logs all team modifications with user, timestamp, action, and before/after state.
|
||
Stores in .teams/audit.log
|
||
"""
|
||
|
||
def __init__(self, project_name: str, audit_dir: Path = None):
|
||
self.project_name = project_name
|
||
self.audit_dir = audit_dir or Path(".teams")
|
||
self.audit_file = self.audit_dir / "audit.log"
|
||
self.audit_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _get_user_id(self, user_context: Optional["UserContext"]) -> str:
|
||
"""Extract user ID from context or return 'system'."""
|
||
if user_context:
|
||
return user_context.user_id
|
||
return "system"
|
||
|
||
def log_action(self, action: str, details: Dict[str, Any], user_context: Optional["UserContext"] = None) -> None:
|
||
"""Log an audit action.
|
||
|
||
Args:
|
||
action: The action performed (e.g., 'assign_role', 'start_team')
|
||
details: Dict containing before/after state and other details
|
||
user_context: Optional user context for RBAC info
|
||
"""
|
||
entry = {
|
||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||
"project": self.project_name,
|
||
"user": self._get_user_id(user_context),
|
||
"role": user_context.role if user_context else "system",
|
||
"action": action,
|
||
"details": details
|
||
}
|
||
|
||
try:
|
||
with open(self.audit_file, 'a') as f:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||
try:
|
||
f.write(json.dumps(entry) + "\n")
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
finally:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||
except Exception as e:
|
||
print(f"⚠️ Audit logging failed: {e}", file=sys.stderr)
|
||
|
||
def query_audit_log(self, start_time: Optional[datetime] = None,
|
||
end_time: Optional[datetime] = None,
|
||
user: Optional[str] = None,
|
||
action: Optional[str] = None,
|
||
team_id: Optional[int] = None,
|
||
limit: int = 100) -> List[Dict[str, Any]]:
|
||
"""Query the audit log with filters.
|
||
|
||
Args:
|
||
start_time: Optional start time filter
|
||
end_time: Optional end time filter
|
||
user: Optional user filter
|
||
action: Optional action filter
|
||
team_id: Optional team_id filter
|
||
limit: Maximum number of entries to return
|
||
|
||
Returns:
|
||
List of matching audit entries
|
||
"""
|
||
if not self.audit_file.exists():
|
||
return []
|
||
|
||
results = []
|
||
try:
|
||
with open(self.audit_file, 'r') as f:
|
||
for line in f:
|
||
if not line.strip():
|
||
continue
|
||
try:
|
||
entry = json.loads(line)
|
||
|
||
# Apply filters
|
||
if start_time:
|
||
entry_time = datetime.fromisoformat(entry["timestamp"].replace("Z", "+00:00"))
|
||
if entry_time < start_time:
|
||
continue
|
||
if end_time:
|
||
entry_time = datetime.fromisoformat(entry["timestamp"].replace("Z", "+00:00"))
|
||
if entry_time > end_time:
|
||
continue
|
||
if user and entry.get("user") != user:
|
||
continue
|
||
if action and entry.get("action") != action:
|
||
continue
|
||
if team_id is not None:
|
||
entry_team_id = entry.get("details", {}).get("team_id")
|
||
if entry_team_id != team_id:
|
||
continue
|
||
|
||
results.append(entry)
|
||
|
||
if len(results) >= limit:
|
||
break
|
||
except json.JSONDecodeError:
|
||
continue
|
||
except Exception as e:
|
||
print(f"⚠️ Audit query failed: {e}", file=sys.stderr)
|
||
|
||
return results
|
||
|
||
def get_recent_actions(self, count: int = 10) -> List[Dict[str, Any]]:
|
||
"""Get the most recent audit actions.
|
||
|
||
Args:
|
||
count: Number of entries to return
|
||
|
||
Returns:
|
||
List of recent audit entries
|
||
"""
|
||
return self.query_audit_log(limit=count)
|
||
|
||
|
||
class UserContext:
|
||
"""User session context with RBAC information."""
|
||
|
||
# Role hierarchy: higher number = more permissions
|
||
ROLE_LEVELS = {
|
||
"viewer": 1, # Can view only
|
||
"team-lead": 2, # Can modify their team's assignments
|
||
"admin": 3 # Can modify everything
|
||
}
|
||
|
||
def __init__(self, user_id: str, role: str, team_id: Optional[int] = None):
|
||
self.user_id = user_id
|
||
self.role = role
|
||
self.team_id = team_id
|
||
|
||
if role not in self.ROLE_LEVELS:
|
||
raise ValueError(f"Invalid role: {role}. Must be one of: {list(self.ROLE_LEVELS.keys())}")
|
||
|
||
def has_permission(self, required_role: str) -> bool:
|
||
"""Check if user has at least the required role level."""
|
||
return self.ROLE_LEVELS.get(self.role, 0) >= self.ROLE_LEVELS.get(required_role, 0)
|
||
|
||
def can_modify_team(self, team_id: int) -> bool:
|
||
"""Check if user can modify a specific team."""
|
||
if self.role == "admin":
|
||
return True
|
||
if self.role == "team-lead" and self.team_id == team_id:
|
||
return True
|
||
return False
|
||
|
||
|
||
class FileLock:
|
||
"""Cross-platform file locking using flock (Unix) or msvcrt (Windows)."""
|
||
|
||
def __init__(self, lock_file_path: Path, timeout: float = 30.0):
|
||
self.lock_file_path = lock_file_path
|
||
self.timeout = timeout
|
||
self.lock_file = None
|
||
|
||
def __enter__(self):
|
||
"""Acquire exclusive lock."""
|
||
self.lock_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||
self.lock_file = open(self.lock_file_path, 'w')
|
||
|
||
try:
|
||
# Use non-blocking flock first
|
||
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except (IOError, OSError):
|
||
# Lock is held by another process
|
||
import time
|
||
start_time = time.time()
|
||
while time.time() - start_time < self.timeout:
|
||
try:
|
||
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
break
|
||
except (IOError, OSError):
|
||
time.sleep(0.1)
|
||
else:
|
||
self.lock_file.close()
|
||
raise FileLockError(f"Could not acquire lock within {self.timeout}s")
|
||
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||
"""Release lock and close file."""
|
||
if self.lock_file:
|
||
try:
|
||
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN)
|
||
except (IOError, OSError):
|
||
pass
|
||
finally:
|
||
self.lock_file.close()
|
||
|
||
|
||
@dataclass
|
||
class Role:
|
||
"""Standard team role."""
|
||
name: str
|
||
responsibility: str
|
||
deliverables: List[str]
|
||
assigned_to: Optional[str] = None
|
||
|
||
|
||
@dataclass
|
||
class Team:
|
||
"""Standard team definition."""
|
||
id: int
|
||
name: str
|
||
phase: str
|
||
description: str
|
||
roles: List[Role]
|
||
exit_criteria: List[str]
|
||
status: str = "not_started" # not_started, active, completed, blocked
|
||
started_at: Optional[str] = None
|
||
completed_at: Optional[str] = None
|
||
|
||
|
||
class TeamManager:
|
||
"""Manages standardized team layout."""
|
||
|
||
# Standard team definitions
|
||
STANDARD_TEAMS = {
|
||
# Phase 1: Strategy, Governance & Planning
|
||
1: Team(
|
||
id=1,
|
||
name="Business & Product Strategy",
|
||
phase="Phase 1: Strategy, Governance & Planning",
|
||
description="The 'Why' - Business case and product strategy",
|
||
roles=[
|
||
Role("Business Relationship Manager", "Connects IT to C-suite",
|
||
["Strategic alignment docs", "Executive briefings"]),
|
||
Role("Lead Product Manager", "Owns long-term roadmap",
|
||
["Product roadmap", "OKRs", "Feature prioritization"]),
|
||
Role("Business Systems Analyst", "Translates business to technical",
|
||
["Requirements specs", "User stories", "Acceptance criteria"]),
|
||
Role("Financial Controller (FinOps)", "Approves budget and cloud spend",
|
||
["Budget forecasts", "Cost projections", "Spend reports"]),
|
||
],
|
||
exit_criteria=[
|
||
"Business case approved",
|
||
"Budget allocated",
|
||
"Roadmap defined",
|
||
"Success metrics established"
|
||
]
|
||
),
|
||
2: Team(
|
||
id=2,
|
||
name="Enterprise Architecture",
|
||
phase="Phase 1: Strategy, Governance & Planning",
|
||
description="The 'Standards' - Technology vision and standards",
|
||
roles=[
|
||
Role("Chief Architect", "Sets 5-year tech vision",
|
||
["Architecture vision", "Tech radar", "Strategic plans"]),
|
||
Role("Domain Architect", "Specialized stack expertise",
|
||
["Domain-specific patterns", "Best practices guides"]),
|
||
Role("Solution Architect", "Maps projects to standards",
|
||
["Solution designs", "Architecture decision records"]),
|
||
Role("Standards Lead", "Manages Approved Tech List",
|
||
["Technology standards", "Evaluation criteria", "Approved list"]),
|
||
],
|
||
exit_criteria=[
|
||
"Architecture approved",
|
||
"Technology choices validated",
|
||
"Standards compliance verified"
|
||
]
|
||
),
|
||
3: Team(
|
||
id=3,
|
||
name="GRC (Governance, Risk, & Compliance)",
|
||
phase="Phase 1: Strategy, Governance & Planning",
|
||
description="Compliance and risk management",
|
||
roles=[
|
||
Role("Compliance Officer", "SOX/HIPAA/GDPR adherence",
|
||
["Compliance checklists", "Audit reports"]),
|
||
Role("Internal Auditor", "Pre-production mock audits",
|
||
["Audit findings", "Remediation plans"]),
|
||
Role("Privacy Engineer", "Data masking and PII",
|
||
["Privacy impact assessments", "Data flow diagrams"]),
|
||
Role("Policy Manager", "Maintains SOPs",
|
||
["Standard operating procedures", "Policy updates"]),
|
||
],
|
||
exit_criteria=[
|
||
"Compliance review passed",
|
||
"Risk assessment complete",
|
||
"Privacy requirements met",
|
||
"Policies acknowledged"
|
||
]
|
||
),
|
||
# Phase 2: Platform & Foundation
|
||
4: Team(
|
||
id=4,
|
||
name="Infrastructure & Cloud Ops",
|
||
phase="Phase 2: Platform & Foundation",
|
||
description="Cloud infrastructure and networking",
|
||
roles=[
|
||
Role("Cloud Architect", "VPC and network design",
|
||
["Network diagrams", "Security groups", "Routing tables"]),
|
||
Role("IaC Engineer", "Provisions the 'metal'",
|
||
["Terraform modules", "Ansible playbooks", "Infrastructure code"]),
|
||
Role("Network Security Engineer", "Firewalls, VPNs, Direct Connect",
|
||
["Security rules", "Network policies", "Access controls"]),
|
||
Role("Storage Engineer", "S3/SAN management",
|
||
["Storage policies", "Backup strategies", "Archival rules"]),
|
||
],
|
||
exit_criteria=[
|
||
"Infrastructure provisioned",
|
||
"Network connectivity verified",
|
||
"Security rules applied",
|
||
"Monitoring enabled"
|
||
]
|
||
),
|
||
5: Team(
|
||
id=5,
|
||
name="Platform Engineering",
|
||
phase="Phase 2: Platform & Foundation",
|
||
description="The 'Internal Tools' - Developer experience platform",
|
||
roles=[
|
||
Role("Platform Product Manager", "Developer experience as product",
|
||
["Platform roadmap", "DX metrics", "Adoption reports"]),
|
||
Role("CI/CD Architect", "Golden pipelines",
|
||
["Pipeline templates", "Build configs", "Deployment strategies"]),
|
||
Role("Kubernetes Administrator", "Cluster management",
|
||
["Cluster configs", "Resource quotas", "Ingress rules"]),
|
||
Role("Developer Advocate", "Dev squad adoption",
|
||
["Onboarding guides", "Training materials", "Feedback loops"]),
|
||
],
|
||
exit_criteria=[
|
||
"Platform services ready",
|
||
"CI/CD pipelines functional",
|
||
"Developer onboarding complete"
|
||
]
|
||
),
|
||
6: Team(
|
||
id=6,
|
||
name="Data Governance & Analytics",
|
||
phase="Phase 2: Platform & Foundation",
|
||
description="Enterprise data management",
|
||
roles=[
|
||
Role("Data Architect", "Enterprise data model",
|
||
["Data models", "Schema designs", "Lineage documentation"]),
|
||
Role("DBA", "Production database performance",
|
||
["Query optimization", "Index tuning", "Backup verification"]),
|
||
Role("Data Privacy Officer", "Retention and deletion rules",
|
||
["Data retention policies", "Deletion workflows"]),
|
||
Role("ETL Developer", "Data flow management",
|
||
["ETL pipelines", "Data quality checks", "Transformation logic"]),
|
||
],
|
||
exit_criteria=[
|
||
"Data models defined",
|
||
"Pipelines operational",
|
||
"Privacy controls implemented"
|
||
]
|
||
),
|
||
# Phase 3: The Build Squads
|
||
7: Team(
|
||
id=7,
|
||
name="Core Feature Squad",
|
||
phase="Phase 3: The Build Squads",
|
||
description="The 'Devs' - Feature implementation",
|
||
roles=[
|
||
Role("Technical Lead", "Final word on implementation",
|
||
["Code reviews", "Architecture decisions", "Technical guidance"]),
|
||
Role("Senior Backend Engineer", "Logic, APIs, microservices",
|
||
["Backend services", "API endpoints", "Business logic"]),
|
||
Role("Senior Frontend Engineer", "Design system, state management",
|
||
["UI components", "Frontend architecture", "State logic"]),
|
||
Role("Accessibility (A11y) Expert", "WCAG compliance",
|
||
["A11y audits", "Remediation plans", "Testing reports"]),
|
||
Role("Technical Writer", "Internal/external docs",
|
||
["API docs", "User guides", "Runbooks"]),
|
||
],
|
||
exit_criteria=[
|
||
"Features implemented",
|
||
"Code reviewed and approved",
|
||
"Documentation complete",
|
||
"A11y requirements met"
|
||
]
|
||
),
|
||
8: Team(
|
||
id=8,
|
||
name="Middleware & Integration",
|
||
phase="Phase 3: The Build Squads",
|
||
description="APIs and system integrations",
|
||
roles=[
|
||
Role("API Product Manager", "API lifecycle and versioning",
|
||
["API specs", "Versioning strategy", "Deprecation plans"]),
|
||
Role("Integration Engineer", "SAP/Oracle/Mainframe connections",
|
||
["Integration specs", "Data mappings", "Error handling"]),
|
||
Role("Messaging Engineer", "Kafka/RabbitMQ management",
|
||
["Topic design", "Message schemas", "Consumer groups"]),
|
||
Role("IAM Specialist", "Okta/AD integration",
|
||
["Auth flows", "Permission models", "Access policies"]),
|
||
],
|
||
exit_criteria=[
|
||
"APIs documented and tested",
|
||
"Integrations verified",
|
||
"Auth flows functional"
|
||
]
|
||
),
|
||
# Phase 4: Validation & Hardening
|
||
9: Team(
|
||
id=9,
|
||
name="Cybersecurity (AppSec)",
|
||
phase="Phase 4: Validation & Hardening",
|
||
description="Application security",
|
||
roles=[
|
||
Role("Security Architect", "Threat model review",
|
||
["Threat models", "Security architecture", "Risk assessments"]),
|
||
Role("Vulnerability Researcher", "SAST/DAST/SCA scanners",
|
||
["Scan reports", "Vulnerability triage", "Fix verification"]),
|
||
Role("Penetration Tester", "Manual security testing",
|
||
["Pen test reports", "Exploit verification", "Remediation"]),
|
||
Role("DevSecOps Engineer", "Security in CI/CD",
|
||
["Security gates", "Pipeline integration", "Compliance checks"]),
|
||
],
|
||
exit_criteria=[
|
||
"Security review passed",
|
||
"Vulnerabilities remediated or accepted",
|
||
"Pen testing complete",
|
||
"Security gates passing"
|
||
]
|
||
),
|
||
10: Team(
|
||
id=10,
|
||
name="Quality Engineering (SDET)",
|
||
phase="Phase 4: Validation & Hardening",
|
||
description="Testing and quality assurance",
|
||
roles=[
|
||
Role("QA Architect", "Global testing strategy",
|
||
["Test strategy", "Test plans", "Coverage reports"]),
|
||
Role("SDET", "Automated test code",
|
||
["Test automation", "Framework maintenance", "CI integration"]),
|
||
Role("Performance/Load Engineer", "Scale testing",
|
||
["Load test scripts", "Performance baselines", "Capacity reports"]),
|
||
Role("Manual QA / UAT Coordinator", "User acceptance testing",
|
||
["Test cases", "UAT coordination", "Sign-off reports"]),
|
||
],
|
||
exit_criteria=[
|
||
"Test coverage requirements met",
|
||
"Performance benchmarks achieved",
|
||
"UAT sign-off obtained"
|
||
]
|
||
),
|
||
# Phase 5: Delivery & Sustainment
|
||
11: Team(
|
||
id=11,
|
||
name="Site Reliability Engineering (SRE)",
|
||
phase="Phase 5: Delivery & Sustainment",
|
||
description="Reliability and observability",
|
||
roles=[
|
||
Role("SRE Lead", "Error budget and uptime SLA",
|
||
["SLOs", "Error budgets", "Reliability reports"]),
|
||
Role("Observability Engineer", "Monitoring and logging",
|
||
["Dashboards", "Alerts", "Log aggregation", "Traces"]),
|
||
Role("Chaos Engineer", "Resiliency testing",
|
||
["Chaos experiments", "Failure scenarios", "Recovery tests"]),
|
||
Role("Incident Manager", "War room leadership",
|
||
["Incident response", "Post-mortems", "Runbook updates"]),
|
||
],
|
||
exit_criteria=[
|
||
"Monitoring in place",
|
||
"Alerts configured",
|
||
"Runbooks complete",
|
||
"Error budget healthy"
|
||
]
|
||
),
|
||
12: Team(
|
||
id=12,
|
||
name="IT Operations & Support (NOC)",
|
||
phase="Phase 5: Delivery & Sustainment",
|
||
description="Production operations",
|
||
roles=[
|
||
Role("NOC Analyst", "24/7 monitoring",
|
||
["Monitoring dashboards", "Alert triage", "Incident tickets"]),
|
||
Role("Change Manager", "Deployment approval",
|
||
["Change requests", "Deployment windows", "CAB approval"]),
|
||
Role("Release Manager", "Go/No-Go coordination",
|
||
["Release plans", "Rollback procedures", "Coordination"]),
|
||
Role("L3 Support Engineer", "Production bug escalation",
|
||
["Root cause analysis", "Hotfix coordination", "KB articles"]),
|
||
],
|
||
exit_criteria=[
|
||
"Change approved",
|
||
"Release deployed",
|
||
"Support handoff complete"
|
||
]
|
||
),
|
||
}
|
||
|
||
def __init__(self, project_name: str, config_path: Path = None, user_context: Optional["UserContext"] = None, logger: Optional[StructuredLogger] = None, enable_backup: bool = True, enable_audit: bool = True, max_backups: int = 10, test_mode: bool = False):
|
||
# SEC-006: Validate project name and path to prevent path traversal
|
||
self.project_name = project_name
|
||
self.teams: Dict[int, Team] = {}
|
||
|
||
# Validate and resolve the config path
|
||
if config_path is not None:
|
||
self.config_path = config_path
|
||
else:
|
||
self.config_path = validate_project_path(project_name)
|
||
|
||
# Validate lock file path is within .teams/
|
||
self.lock_path = validate_project_path(project_name).with_suffix(".lock")
|
||
|
||
self.user_context = user_context
|
||
self.logger = logger or StructuredLogger("team_manager")
|
||
self.test_mode = test_mode
|
||
|
||
# OPS-004: Backup manager
|
||
self.enable_backup = enable_backup
|
||
if enable_backup:
|
||
self.backup_manager = BackupManager(project_name, max_backups=max_backups)
|
||
else:
|
||
self.backup_manager = None
|
||
|
||
# SEC-008: Audit logger
|
||
self.enable_audit = enable_audit
|
||
if enable_audit:
|
||
self.audit_logger = AuditLogger(project_name)
|
||
else:
|
||
self.audit_logger = None
|
||
|
||
# OPS-006: Migration manager
|
||
self.migration_manager = MigrationManager(project_name)
|
||
|
||
# OPS-005: Version tracking
|
||
self._data_version = "1.0.0"
|
||
|
||
# OPS-008: Performance metrics
|
||
self.performance_metrics = PerformanceMetrics(project_name)
|
||
|
||
# SEC-005: Rate limiter (lazy initialization)
|
||
self._rate_limiter: Optional[RateLimiter] = None
|
||
|
||
# SEC-007: Encryption manager
|
||
self.encryption_manager = EncryptionManager()
|
||
|
||
def _check_rate_limit(self, user_id: str = "default") -> tuple[bool, dict]:
|
||
"""Check rate limit for the current user (SEC-005).
|
||
|
||
Args:
|
||
user_id: Unique identifier for the user
|
||
|
||
Returns:
|
||
Tuple of (allowed, rate_limit_info)
|
||
"""
|
||
if self._rate_limiter is None:
|
||
self._rate_limiter = RateLimiter()
|
||
return self._rate_limiter.check_rate_limit(user_id)
|
||
|
||
def _get_rate_limit_headers(self, user_id: str = "default") -> Dict[str, str]:
|
||
"""Get rate limit headers for response (SEC-005)."""
|
||
if self._rate_limiter is None:
|
||
return {}
|
||
return self._rate_limiter.get_rate_limit_headers(user_id)
|
||
|
||
def _require_auth(self, operation: str, team_id: Optional[int] = None) -> None:
|
||
"""Check if user is authorized for the operation."""
|
||
# Skip auth checks in test mode
|
||
if self.test_mode:
|
||
return
|
||
|
||
if self.user_context is None:
|
||
raise PermissionDenied(f"Authentication required for {operation}")
|
||
|
||
if not self.user_context.has_permission("team-lead"):
|
||
raise PermissionDenied(
|
||
f"User '{self.user_context.user_id}' with role '{self.user_context.role}' "
|
||
f"does not have permission to {operation}"
|
||
)
|
||
|
||
if team_id is not None and not self.user_context.can_modify_team(team_id):
|
||
raise PermissionDenied(
|
||
f"User '{self.user_context.user_id}' cannot modify team {team_id}. "
|
||
f"Requires admin role or team-lead for this specific team."
|
||
)
|
||
|
||
def initialize_project(self) -> None:
|
||
"""Initialize a new project with all teams.
|
||
|
||
Requires admin role.
|
||
"""
|
||
self.performance_metrics.start_operation("init", project=self.project_name)
|
||
try:
|
||
self._require_auth("initialize project")
|
||
self.teams = {team_id: team for team_id, team in self.STANDARD_TEAMS.items()}
|
||
self.save()
|
||
print(f"✅ Initialized project '{self.project_name}' with {len(self.teams)} teams")
|
||
self.performance_metrics.end_operation("init", success=True, team_count=len(self.teams))
|
||
except Exception as e:
|
||
self.performance_metrics.end_operation("init", success=False, error_type=type(e).__name__)
|
||
raise
|
||
|
||
def load(self) -> bool:
|
||
"""Load team configuration from disk with file locking.
|
||
|
||
Uses shared lock to allow concurrent reads while preventing
|
||
reads during writes.
|
||
"""
|
||
if not self.config_path.exists():
|
||
return False
|
||
|
||
# Create lock file if it doesn't exist
|
||
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
with FileLock(self.lock_path):
|
||
with open(self.config_path, 'r') as f:
|
||
# Use shared lock for reads
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||
try:
|
||
data = json.load(f)
|
||
finally:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||
|
||
# SEC-007: Decrypt data if encryption is detected and enabled
|
||
if self.encryption_manager.enabled and self.encryption_manager.is_encrypted(data):
|
||
data = self.encryption_manager.decrypt_data(data)
|
||
self.logger.info("data_decrypted", {"project": self.project_name})
|
||
|
||
self.teams = {}
|
||
for team_data in data.get("teams", []):
|
||
team = Team(**team_data)
|
||
team.roles = [Role(**r) for r in team_data.get("roles", [])]
|
||
self.teams[team.id] = team
|
||
|
||
# OPS-005: Store version info
|
||
self._data_version = data.get("version", "1.0.0")
|
||
|
||
# OPS-006: Apply migrations if needed
|
||
if self.migration_manager.needs_migration(data):
|
||
data = self.migration_manager.migrate(data)
|
||
# Save migrated data back
|
||
with FileLock(self.lock_path):
|
||
with open(self.config_path, 'w') as f:
|
||
json.dump(data, f, indent=2)
|
||
|
||
return True
|
||
|
||
def save(self) -> None:
|
||
"""Save team configuration to disk.
|
||
|
||
OPS-004: Creates automatic backup before saving.
|
||
"""
|
||
self.logger.info("config_save_start", {"config_path": str(self.config_path)})
|
||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# OPS-004: Create backup before write if config exists
|
||
backup_path = None
|
||
if self.enable_backup and self.backup_manager and self.config_path.exists():
|
||
backup_path = self.backup_manager.create_backup(self.config_path)
|
||
if backup_path:
|
||
self.logger.info("backup_created", {"backup_path": str(backup_path)})
|
||
|
||
data = {
|
||
"project_name": self.project_name,
|
||
"version": "1.0.0", # OPS-005: Version tracking
|
||
"updated_at": datetime.now().isoformat(),
|
||
"teams": [asdict(team) for team in self.teams.values()]
|
||
}
|
||
|
||
# SEC-007: Encrypt sensitive data if encryption is enabled
|
||
if self.encryption_manager.enabled:
|
||
data = self.encryption_manager.encrypt_data(data)
|
||
self.logger.info("data_encrypted", {"project": self.project_name})
|
||
|
||
try:
|
||
# Use file locking to prevent race conditions (SEC-004)
|
||
with FileLock(self.lock_path):
|
||
# Atomic write: write to temp file, then rename
|
||
fd, temp_path = tempfile.mkstemp(
|
||
dir=self.config_path.parent,
|
||
prefix=f".{self.project_name}.tmp."
|
||
)
|
||
try:
|
||
with os.fdopen(fd, 'w') as f:
|
||
json.dump(data, f, indent=2)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
|
||
# Atomic rename
|
||
os.replace(temp_path, self.config_path)
|
||
except Exception:
|
||
# Clean up temp file on error
|
||
try:
|
||
os.unlink(temp_path)
|
||
except FileNotFoundError:
|
||
pass
|
||
raise
|
||
|
||
self.logger.info("config_saved", {
|
||
"config_path": str(self.config_path),
|
||
"team_count": len(self.teams),
|
||
"backup_path": str(backup_path) if backup_path else None
|
||
})
|
||
except Exception as e:
|
||
self.logger.error("config_save_failed", {
|
||
"config_path": str(self.config_path),
|
||
"error": str(e)
|
||
}, exc_info=True)
|
||
raise
|
||
|
||
def assign_role(self, team_id: int, role_name: str, assignee: str) -> bool:
|
||
"""Assign a person to a role.
|
||
|
||
Requires team-lead role (for their team) or admin role.
|
||
SEC-008: Logs audit trail.
|
||
"""
|
||
self.performance_metrics.start_operation("assign", team_id=team_id, role_name=role_name, assignee=assignee)
|
||
|
||
# Validate inputs (FUNC-003, SEC-002, SEC-003)
|
||
try:
|
||
validate_role_name(role_name)
|
||
validate_person_name(assignee)
|
||
except ValueError as e:
|
||
self.logger.error("role_assignment_validation_failed", {
|
||
"team_id": team_id,
|
||
"role_name": role_name,
|
||
"assignee": assignee,
|
||
"error": str(e)
|
||
})
|
||
self.performance_metrics.end_operation("assign", success=False, error_type="validation_error")
|
||
print(f"❌ Validation error: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
self._require_auth("assign role", team_id)
|
||
|
||
# SEC-005: Check rate limit
|
||
user_id = self.user_context.user_id if self.user_context else "default"
|
||
allowed, rate_info = self._check_rate_limit(user_id)
|
||
if not allowed:
|
||
retry_after = rate_info.get("reset_time", 60)
|
||
self.logger.error("rate_limit_exceeded", {
|
||
"user_id": user_id,
|
||
"operation": "assign_role",
|
||
"retry_after": retry_after
|
||
})
|
||
print(f"❌ Rate limit exceeded. Retry after {retry_after} seconds.", file=sys.stderr)
|
||
return False
|
||
|
||
# FUNC-012: Check for duplicate assignments
|
||
dup_check = self.check_duplicate_assignment(assignee, team_id)
|
||
if dup_check["is_duplicate"]:
|
||
if dup_check["action"] == "block":
|
||
self.logger.error("duplicate_assignment_blocked", {
|
||
"team_id": team_id,
|
||
"role_name": role_name,
|
||
"assignee": assignee,
|
||
"existing": dup_check["existing_assignments"]
|
||
})
|
||
print(dup_check["message"], file=sys.stderr)
|
||
return False
|
||
else:
|
||
# Warn but continue
|
||
print(dup_check["message"], file=sys.stderr)
|
||
|
||
self.logger.info("role_assignment_start", {
|
||
"team_id": team_id,
|
||
"role_name": role_name,
|
||
"assignee": assignee,
|
||
"user_id": self.user_context.user_id if self.user_context else None
|
||
})
|
||
|
||
if team_id not in self.teams:
|
||
self.logger.error("team_not_found", {"team_id": team_id})
|
||
return False
|
||
|
||
team = self.teams[team_id]
|
||
for role in team.roles:
|
||
if role.name == role_name:
|
||
# SEC-008: Capture before state for audit
|
||
previous_assignee = role.assigned_to
|
||
role.assigned_to = assignee
|
||
self.save()
|
||
|
||
# SEC-008: Log audit trail
|
||
if self.enable_audit and self.audit_logger:
|
||
self.audit_logger.log_action(
|
||
"assign_role",
|
||
{
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"role_name": role_name,
|
||
"before": previous_assignee,
|
||
"after": assignee
|
||
},
|
||
self.user_context
|
||
)
|
||
|
||
self.logger.info("role_assigned", {
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"role_name": role_name,
|
||
"assignee": assignee
|
||
})
|
||
self.performance_metrics.end_operation("assign", success=True, team_id=team_id, role_name=role_name)
|
||
return True
|
||
|
||
self.logger.error("role_not_found", {
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"role_name": role_name
|
||
})
|
||
self.performance_metrics.end_operation("assign", success=False, error_type="role_not_found")
|
||
return False
|
||
|
||
def unassign_role(self, team_id: int, role_name: str) -> bool:
|
||
"""Remove assignment from a role.
|
||
|
||
SEC-008: Logs audit trail.
|
||
"""
|
||
if team_id not in self.teams:
|
||
print(f"❌ Team {team_id} not found")
|
||
return False
|
||
|
||
team = self.teams[team_id]
|
||
for role in team.roles:
|
||
if role.name == role_name:
|
||
if role.assigned_to is None:
|
||
print(f"⚠️ Role '{role_name}' in {team.name} is already unassigned")
|
||
return False
|
||
previous_assignee = role.assigned_to
|
||
role.assigned_to = None
|
||
self.save()
|
||
|
||
# SEC-008: Log audit trail
|
||
if self.enable_audit and self.audit_logger:
|
||
self.audit_logger.log_action(
|
||
"unassign_role",
|
||
{
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"role_name": role_name,
|
||
"before": previous_assignee,
|
||
"after": None
|
||
},
|
||
self.user_context
|
||
)
|
||
|
||
print(f"✅ Unassigned {previous_assignee} from {role_name} in {team.name}")
|
||
return True
|
||
|
||
print(f"❌ Role '{role_name}' not found in {team.name}")
|
||
return False
|
||
|
||
def reassign_role(self, team_id: int, from_role: str, to_role: str, person: str) -> bool:
|
||
"""Reassign a person from one role to another within the same team.
|
||
|
||
FUNC-009: Role reassignment capability.
|
||
|
||
Args:
|
||
team_id: The team ID
|
||
from_role: The role to move person from
|
||
to_role: The role to move person to
|
||
person: The person to reassign
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
# Validate inputs
|
||
try:
|
||
validate_role_name(from_role)
|
||
validate_role_name(to_role)
|
||
validate_person_name(person)
|
||
except ValueError as e:
|
||
self.logger.error("reassignment_validation_failed", {
|
||
"team_id": team_id,
|
||
"from_role": from_role,
|
||
"to_role": to_role,
|
||
"person": person,
|
||
"error": str(e)
|
||
})
|
||
print(f"❌ Validation error: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
self._require_auth("reassign role", team_id)
|
||
|
||
if team_id not in self.teams:
|
||
self.logger.error("team_not_found", {"team_id": team_id})
|
||
print(f"❌ Team {team_id} not found")
|
||
return False
|
||
|
||
team = self.teams[team_id]
|
||
|
||
# Find both roles
|
||
from_role_obj = None
|
||
to_role_obj = None
|
||
for role in team.roles:
|
||
if role.name == from_role:
|
||
from_role_obj = role
|
||
if role.name == to_role:
|
||
to_role_obj = role
|
||
|
||
# Validate both roles exist
|
||
if from_role_obj is None:
|
||
self.logger.error("from_role_not_found", {
|
||
"team_id": team_id,
|
||
"from_role": from_role
|
||
})
|
||
print(f"❌ Role '{from_role}' not found in {team.name}")
|
||
return False
|
||
|
||
if to_role_obj is None:
|
||
self.logger.error("to_role_not_found", {
|
||
"team_id": team_id,
|
||
"to_role": to_role
|
||
})
|
||
print(f"❌ Role '{to_role}' not found in {team.name}")
|
||
return False
|
||
|
||
# Validate person is actually assigned to from_role
|
||
if from_role_obj.assigned_to != person:
|
||
self.logger.error("person_not_assigned_to_from_role", {
|
||
"team_id": team_id,
|
||
"from_role": from_role,
|
||
"person": person,
|
||
"actual_assignee": from_role_obj.assigned_to
|
||
})
|
||
print(f"❌ '{person}' is not assigned to '{from_role}' in {team.name}")
|
||
return False
|
||
|
||
# Perform reassignment
|
||
previous_assignee = to_role_obj.assigned_to
|
||
from_role_obj.assigned_to = None
|
||
to_role_obj.assigned_to = person
|
||
self.save()
|
||
|
||
# Log audit trail
|
||
if self.enable_audit and self.audit_logger:
|
||
self.audit_logger.log_action(
|
||
"reassign_role",
|
||
{
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"person": person,
|
||
"from_role": from_role,
|
||
"to_role": to_role,
|
||
"to_role_previous_assignee": previous_assignee
|
||
},
|
||
self.user_context
|
||
)
|
||
|
||
self.logger.info("role_reassigned", {
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"person": person,
|
||
"from_role": from_role,
|
||
"to_role": to_role
|
||
})
|
||
|
||
if previous_assignee:
|
||
print(f"✅ Reassigned {person} from '{from_role}' to '{to_role}' in {team.name}")
|
||
print(f" Note: {previous_assignee} was previously assigned to '{to_role}'")
|
||
else:
|
||
print(f"✅ Reassigned {person} from '{from_role}' to '{to_role}' in {team.name}")
|
||
return True
|
||
|
||
def start_team(self, team_id: int, override: bool = False, reason: Optional[str] = None) -> bool:
|
||
"""Mark a team as active.
|
||
|
||
SEC-008: Logs audit trail.
|
||
FUNC-010: Supports override for admin users.
|
||
|
||
Args:
|
||
team_id: The team ID to start
|
||
override: Whether to override phase gate checks (requires admin)
|
||
reason: Reason for override (required if override=True)
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
self.performance_metrics.start_operation("start", team_id=team_id, override=override)
|
||
self.logger.info("team_start_request", {
|
||
"team_id": team_id,
|
||
"override": override,
|
||
"reason": reason
|
||
})
|
||
|
||
if team_id not in self.teams:
|
||
self.performance_metrics.end_operation("start", success=False, error_type="team_not_found")
|
||
self.logger.error("team_not_found", {"team_id": team_id})
|
||
return False
|
||
|
||
team = self.teams[team_id]
|
||
|
||
# FUNC-010: Check for override capability
|
||
if override:
|
||
# Verify admin role
|
||
if self.user_context is None or not self.user_context.has_permission("admin"):
|
||
self.logger.error("override_permission_denied", {
|
||
"team_id": team_id,
|
||
"user": self.user_context.user_id if self.user_context else None,
|
||
"role": self.user_context.role if self.user_context else None
|
||
})
|
||
print(f"❌ Override requires admin role")
|
||
return False
|
||
|
||
if not reason:
|
||
self.logger.error("override_missing_reason", {"team_id": team_id})
|
||
print(f"❌ Override requires a reason (--reason)")
|
||
return False
|
||
|
||
self.logger.warn("phase_gate_override", {
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"reason": reason,
|
||
"user": self.user_context.user_id if self.user_context else "system"
|
||
})
|
||
|
||
team = self.teams[team_id]
|
||
previous_status = team.status
|
||
team.status = "active"
|
||
team.started_at = datetime.now().isoformat()
|
||
self.save()
|
||
|
||
# SEC-008: Log audit trail
|
||
if self.enable_audit and self.audit_logger:
|
||
self.audit_logger.log_action(
|
||
"start_team",
|
||
{
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"before": previous_status,
|
||
"after": "active",
|
||
"started_at": team.started_at
|
||
},
|
||
self.user_context
|
||
)
|
||
|
||
self.logger.info("team_started", {
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"status": team.status,
|
||
"started_at": team.started_at
|
||
})
|
||
self.performance_metrics.end_operation("start", success=True, team_id=team_id)
|
||
return True
|
||
|
||
def complete_team(self, team_id: int) -> bool:
|
||
"""Mark a team as completed.
|
||
|
||
SEC-008: Logs audit trail.
|
||
"""
|
||
self.performance_metrics.start_operation("complete", team_id=team_id)
|
||
self.logger.info("team_complete_request", {"team_id": team_id})
|
||
|
||
if team_id not in self.teams:
|
||
self.logger.error("team_not_found", {"team_id": team_id})
|
||
self.performance_metrics.end_operation("complete", success=False, error_type="team_not_found")
|
||
return False
|
||
|
||
team = self.teams[team_id]
|
||
previous_status = team.status
|
||
team.status = "completed"
|
||
team.completed_at = datetime.now().isoformat()
|
||
self.save()
|
||
|
||
# SEC-008: Log audit trail
|
||
if self.enable_audit and self.audit_logger:
|
||
self.audit_logger.log_action(
|
||
"complete_team",
|
||
{
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"before": previous_status,
|
||
"after": "completed",
|
||
"completed_at": team.completed_at
|
||
},
|
||
self.user_context
|
||
)
|
||
|
||
self.logger.info("team_completed", {
|
||
"team_id": team_id,
|
||
"team_name": team.name,
|
||
"status": team.status,
|
||
"completed_at": team.completed_at
|
||
})
|
||
self.performance_metrics.end_operation("complete", success=True, team_id=team_id)
|
||
return True
|
||
|
||
def query_teams(self, status: Optional[str] = None, phase: Optional[str] = None,
|
||
assignee: Optional[str] = None, role_name: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""Query teams with filters.
|
||
|
||
FUNC-006: Query API for filtering teams by status, phase, assignee, or role.
|
||
|
||
Args:
|
||
status: Filter by team status (not_started, active, completed, blocked)
|
||
phase: Filter by phase name
|
||
assignee: Filter by person assigned to any role
|
||
role_name: Filter by specific role name
|
||
|
||
Returns:
|
||
List of teams matching all specified filters (AND logic)
|
||
"""
|
||
results = []
|
||
|
||
for team in self.teams.values():
|
||
# Check status filter
|
||
if status is not None and team.status != status:
|
||
continue
|
||
|
||
# Check phase filter
|
||
if phase is not None and team.phase != phase:
|
||
continue
|
||
|
||
# Check assignee filter - person assigned to any role in team
|
||
if assignee is not None:
|
||
assigned_roles = [r for r in team.roles if r.assigned_to == assignee]
|
||
if not assigned_roles:
|
||
continue
|
||
|
||
# Check role_name filter - specific role exists in team
|
||
if role_name is not None:
|
||
matching_roles = [r for r in team.roles if r.name == role_name]
|
||
if not matching_roles:
|
||
continue
|
||
|
||
# Team passed all filters - build result
|
||
team_data = {
|
||
"id": team.id,
|
||
"name": team.name,
|
||
"phase": team.phase,
|
||
"description": team.description,
|
||
"status": team.status,
|
||
"started_at": team.started_at,
|
||
"completed_at": team.completed_at,
|
||
"assigned_count": sum(1 for r in team.roles if r.assigned_to),
|
||
"total_roles": len(team.roles),
|
||
"roles": []
|
||
}
|
||
|
||
# Include role details, filtered if assignee or role_name specified
|
||
for role in team.roles:
|
||
if assignee is not None and role.assigned_to != assignee:
|
||
continue
|
||
if role_name is not None and role.name != role_name:
|
||
continue
|
||
team_data["roles"].append({
|
||
"name": role.name,
|
||
"assigned_to": role.assigned_to,
|
||
"responsibility": role.responsibility
|
||
})
|
||
|
||
results.append(team_data)
|
||
|
||
self.logger.info("teams_queried", {
|
||
"status_filter": status,
|
||
"phase_filter": phase,
|
||
"assignee_filter": assignee,
|
||
"role_filter": role_name,
|
||
"result_count": len(results)
|
||
})
|
||
|
||
return results
|
||
|
||
def get_phase_status(self, phase: str) -> dict:
|
||
"""Get status summary for a phase."""
|
||
phase_teams = [t for t in self.teams.values() if t.phase == phase]
|
||
|
||
total = len(phase_teams)
|
||
completed = len([t for t in phase_teams if t.status == "completed"])
|
||
active = len([t for t in phase_teams if t.status == "active"])
|
||
|
||
result = {
|
||
"phase": phase,
|
||
"total_teams": total,
|
||
"completed": completed,
|
||
"active": active,
|
||
"not_started": total - completed - active,
|
||
"progress_pct": (completed / total * 100) if total > 0 else 0
|
||
}
|
||
|
||
self.logger.debug("phase_status_queried", {"phase": phase, "status": result})
|
||
return result
|
||
|
||
def list_teams(self, phase: str = None) -> None:
|
||
"""Print all teams."""
|
||
# Validate phase filter if provided (FUNC-004)
|
||
if phase is not None:
|
||
try:
|
||
validate_phase(phase)
|
||
except ValueError as e:
|
||
self.logger.error("list_teams_validation_failed", {"error": str(e)})
|
||
print(f"❌ Validation error: {e}", file=sys.stderr)
|
||
return
|
||
|
||
teams = self.teams.values()
|
||
if phase:
|
||
teams = [t for t in teams if t.phase == phase]
|
||
|
||
team_list = []
|
||
for team in sorted(teams, key=lambda t: (t.phase, t.id)):
|
||
assigned_roles = sum(1 for r in team.roles if r.assigned_to)
|
||
team_list.append({
|
||
"id": team.id,
|
||
"name": team.name,
|
||
"phase": team.phase,
|
||
"status": team.status,
|
||
"assigned_count": assigned_roles,
|
||
"total_roles": len(team.roles)
|
||
})
|
||
|
||
self.logger.info("teams_listed", {
|
||
"phase_filter": phase,
|
||
"team_count": len(team_list),
|
||
"teams": team_list
|
||
})
|
||
|
||
# Print team information to stdout
|
||
if team_list:
|
||
print(f"\nProject: {self.project_name}")
|
||
print("=" * 50)
|
||
for team in team_list:
|
||
print(f"\nTeam {team['id']}: {team['name']}")
|
||
print(f" Phase: {team['phase']}")
|
||
print(f" Status: {team['status']}")
|
||
print(f" Assigned: {team['assigned_count']}/{team['total_roles']}")
|
||
# Show assigned roles
|
||
t = self.teams.get(team['id'])
|
||
if t:
|
||
for role in t.roles:
|
||
if role.assigned_to:
|
||
print(f" - {role.name}: {role.assigned_to}")
|
||
else:
|
||
print("No teams found.")
|
||
|
||
def get_agent_team(self, agent_type: str) -> Optional[Team]:
|
||
"""Map agent type to appropriate team."""
|
||
mapping = {
|
||
"planner": 2, # Enterprise Architecture
|
||
"coder": 7, # Core Feature Squad
|
||
"reviewer": 10, # Quality Engineering
|
||
"security": 9, # Cybersecurity
|
||
"tester": 10, # Quality Engineering
|
||
"ops": 11, # SRE
|
||
}
|
||
team_id = mapping.get(agent_type.lower())
|
||
team = self.teams.get(team_id) if team_id else None
|
||
|
||
self.logger.debug("agent_team_mapped", {
|
||
"agent_type": agent_type,
|
||
"team_id": team_id,
|
||
"found": team is not None
|
||
})
|
||
return team
|
||
|
||
def validate_team_size(self, team_id: Optional[int] = None) -> dict:
|
||
"""Validate team sizes meet configured member requirement.
|
||
|
||
Returns dict with validation results.
|
||
Uses team_size_limits from rules.json (FUNC-008).
|
||
"""
|
||
loader = get_rules_loader()
|
||
MIN_TEAM_SIZE, MAX_TEAM_SIZE = loader.get_team_size_limits()
|
||
|
||
results = {
|
||
"valid": True,
|
||
"violations": [],
|
||
"teams_checked": 0
|
||
}
|
||
|
||
teams_to_check = [self.teams[team_id]] if team_id else self.teams.values()
|
||
|
||
for team in teams_to_check:
|
||
results["teams_checked"] += 1
|
||
assigned_count = sum(1 for role in team.roles if role.assigned_to)
|
||
|
||
if assigned_count < MIN_TEAM_SIZE:
|
||
results["valid"] = False
|
||
results["violations"].append({
|
||
"team_id": team.id,
|
||
"team_name": team.name,
|
||
"issue": "undersized",
|
||
"assigned": assigned_count,
|
||
"required": MIN_TEAM_SIZE
|
||
})
|
||
self.logger.warn("team_undersized", {
|
||
"team_id": team.id,
|
||
"team_name": team.name,
|
||
"assigned": assigned_count,
|
||
"required": MIN_TEAM_SIZE
|
||
})
|
||
elif assigned_count > MAX_TEAM_SIZE:
|
||
results["valid"] = False
|
||
results["violations"].append({
|
||
"team_id": team.id,
|
||
"team_name": team.name,
|
||
"issue": "oversized",
|
||
"assigned": assigned_count,
|
||
"maximum": MAX_TEAM_SIZE
|
||
})
|
||
self.logger.warn("team_oversized", {
|
||
"team_id": team.id,
|
||
"team_name": team.name,
|
||
"assigned": assigned_count,
|
||
"maximum": MAX_TEAM_SIZE
|
||
})
|
||
|
||
if results["valid"]:
|
||
self.logger.info("team_size_validation_passed", {
|
||
"teams_checked": results["teams_checked"]
|
||
})
|
||
else:
|
||
self.logger.warn("team_size_validation_failed", {
|
||
"teams_checked": results["teams_checked"],
|
||
"violation_count": len(results["violations"]),
|
||
"violations": results["violations"]
|
||
})
|
||
|
||
return results
|
||
|
||
def delete_team(self, team_id: int, confirmed: bool = False) -> dict:
|
||
"""Delete a specific team from the project.
|
||
|
||
Args:
|
||
team_id: The ID of the team to delete
|
||
confirmed: Whether deletion is confirmed (safety check)
|
||
|
||
Returns:
|
||
dict with deletion result
|
||
"""
|
||
result = {
|
||
"success": False,
|
||
"team_id": team_id,
|
||
"message": "",
|
||
"requires_confirmation": False
|
||
}
|
||
|
||
if team_id not in self.teams:
|
||
result["message"] = f"❌ Team {team_id} not found in project '{self.project_name}'"
|
||
return result
|
||
|
||
team = self.teams[team_id]
|
||
|
||
if not confirmed:
|
||
result["requires_confirmation"] = True
|
||
result["message"] = (
|
||
f"⚠️ Deletion requires confirmation. "
|
||
f"Team {team_id} ({team.name}) will be permanently removed. "
|
||
f"Set confirmed=true to proceed."
|
||
)
|
||
return result
|
||
|
||
# Capture team data before deletion for audit
|
||
team_data = asdict(team)
|
||
deleted_team_name = team.name
|
||
del self.teams[team_id]
|
||
self.save()
|
||
|
||
# SEC-008: Log audit trail
|
||
if self.enable_audit and self.audit_logger:
|
||
self.audit_logger.log_action(
|
||
"delete_team",
|
||
{
|
||
"team_id": team_id,
|
||
"team_name": deleted_team_name,
|
||
"deleted_data": team_data
|
||
},
|
||
self.user_context
|
||
)
|
||
|
||
result["success"] = True
|
||
result["message"] = f"✅ Team {team_id} ({deleted_team_name}) deleted from project '{self.project_name}'"
|
||
return result
|
||
|
||
def delete_project(self, confirmed: bool = False) -> dict:
|
||
"""Delete the entire project.
|
||
|
||
Args:
|
||
confirmed: Whether deletion is confirmed (safety check)
|
||
|
||
Returns:
|
||
dict with deletion result
|
||
"""
|
||
result = {
|
||
"success": False,
|
||
"project_name": self.project_name,
|
||
"message": "",
|
||
"requires_confirmation": False
|
||
}
|
||
|
||
if not self.config_path.exists():
|
||
result["message"] = f"❌ Project '{self.project_name}' not found"
|
||
return result
|
||
|
||
if not confirmed:
|
||
result["requires_confirmation"] = True
|
||
team_count = len(self.teams)
|
||
result["message"] = (
|
||
f"⚠️ Deletion requires confirmation. "
|
||
f"Project '{self.project_name}' with {team_count} team(s) will be permanently deleted. "
|
||
f"Set confirmed=true to proceed."
|
||
)
|
||
return result
|
||
|
||
# Capture project data before deletion for audit
|
||
team_count = len(self.teams)
|
||
deletion_time = datetime.now().isoformat()
|
||
|
||
# SEC-008: Log audit trail before deletion
|
||
if self.enable_audit and self.audit_logger:
|
||
self.audit_logger.log_action(
|
||
"delete_project",
|
||
{
|
||
"project_name": self.project_name,
|
||
"team_count": team_count,
|
||
"teams": [asdict(team) for team in self.teams.values()],
|
||
"deleted_at": deletion_time
|
||
},
|
||
self.user_context
|
||
)
|
||
|
||
# Delete the project file
|
||
try:
|
||
self.config_path.unlink()
|
||
|
||
result["success"] = True
|
||
result["message"] = f"✅ Project '{self.project_name}' ({team_count} teams) deleted successfully"
|
||
except Exception as e:
|
||
result["message"] = f"❌ Error deleting project: {e}"
|
||
|
||
|
||
# FUNC-012: Duplicate Detection Methods
|
||
def get_person_assignments(self, person: str) -> List[Dict[str, Any]]:
|
||
"""Get all role assignments for a person across all teams.
|
||
|
||
Args:
|
||
person: The person name/email to look up
|
||
|
||
Returns:
|
||
List of assignments with team_id, team_name, role_name
|
||
"""
|
||
assignments = []
|
||
person_lower = person.lower()
|
||
|
||
for team in self.teams.values():
|
||
for role in team.roles:
|
||
if role.assigned_to and role.assigned_to.lower() == person_lower:
|
||
assignments.append({
|
||
"team_id": team.id,
|
||
"team_name": team.name,
|
||
"role_name": role.name,
|
||
"person": role.assigned_to
|
||
})
|
||
|
||
return assignments
|
||
|
||
def check_duplicate_assignment(self, person: str, team_id: Optional[int] = None) -> Dict[str, Any]:
|
||
"""Check if assigning this person would create a duplicate.
|
||
|
||
FUNC-012: Duplicate detection with configurable scope and action.
|
||
|
||
Args:
|
||
person: The person being assigned
|
||
team_id: The team being assigned to (optional, for scope checking)
|
||
|
||
Returns:
|
||
Dict with duplicate check results:
|
||
{
|
||
"is_duplicate": bool,
|
||
"existing_assignments": List[Dict],
|
||
"action": "allow" | "warn" | "block",
|
||
"message": str
|
||
}
|
||
"""
|
||
loader = get_rules_loader()
|
||
config = loader.get_duplicate_detection_config()
|
||
|
||
result = {
|
||
"is_duplicate": False,
|
||
"existing_assignments": [],
|
||
"action": "allow",
|
||
"message": ""
|
||
}
|
||
|
||
if not config.get("enabled", True):
|
||
return result
|
||
|
||
scope = config.get("scope", "project")
|
||
action = config.get("action", "warn")
|
||
|
||
# Get existing assignments
|
||
existing = self.get_person_assignments(person)
|
||
|
||
if not existing:
|
||
return result
|
||
|
||
# Check scope
|
||
if scope == "team":
|
||
# Only check if person is already in the same team
|
||
existing = [a for a in existing if a["team_id"] == team_id]
|
||
|
||
if existing:
|
||
result["is_duplicate"] = True
|
||
result["existing_assignments"] = existing
|
||
result["action"] = action
|
||
|
||
if action == "block":
|
||
result["message"] = f"❌ Cannot assign '{person}': already assigned to {len(existing)} role(s)"
|
||
else:
|
||
result["message"] = f"⚠️ Warning: '{person}' is already assigned to {len(existing)} role(s)"
|
||
|
||
return result
|
||
|
||
def validate_no_duplicates(self, team_id: Optional[int] = None) -> Dict[str, Any]:
|
||
"""Validate entire project for duplicate assignments.
|
||
|
||
Args:
|
||
team_id: Optional team to limit validation to
|
||
|
||
Returns:
|
||
Dict with validation results
|
||
"""
|
||
loader = get_rules_loader()
|
||
config = loader.get_duplicate_detection_config()
|
||
|
||
result = {
|
||
"valid": True,
|
||
"duplicates": [],
|
||
"total_affected": 0
|
||
}
|
||
|
||
if not config.get("enabled", True):
|
||
return result
|
||
|
||
# Build map of person -> assignments
|
||
person_assignments: Dict[str, List[Dict]] = {}
|
||
|
||
teams_to_check = [self.teams[team_id]] if team_id else self.teams.values()
|
||
|
||
for team in teams_to_check:
|
||
for role in team.roles:
|
||
if role.assigned_to:
|
||
person = role.assigned_to.lower()
|
||
if person not in person_assignments:
|
||
person_assignments[person] = []
|
||
person_assignments[person].append({
|
||
"team_id": team.id,
|
||
"team_name": team.name,
|
||
"role_name": role.name,
|
||
"person": role.assigned_to
|
||
})
|
||
|
||
# Find duplicates (people with multiple assignments)
|
||
for person, assignments in person_assignments.items():
|
||
if len(assignments) > 1:
|
||
result["valid"] = False
|
||
result["duplicates"].append({
|
||
"person": person,
|
||
"assignment_count": len(assignments),
|
||
"assignments": assignments
|
||
})
|
||
result["total_affected"] += 1
|
||
|
||
return result
|
||
|
||
return result
|
||
|
||
def list_backups(self) -> List[Dict[str, Any]]:
|
||
"""List all available backups for this project.
|
||
|
||
Returns:
|
||
List of backup info dicts
|
||
"""
|
||
if self.backup_manager:
|
||
return self.backup_manager.list_backups()
|
||
return []
|
||
|
||
def restore_backup(self, backup_filename: str) -> dict:
|
||
"""Restore from a backup file.
|
||
|
||
Args:
|
||
backup_filename: Name of the backup file to restore
|
||
|
||
Returns:
|
||
Dict with restore result
|
||
"""
|
||
result = {
|
||
"success": False,
|
||
"message": "",
|
||
"backup_file": backup_filename
|
||
}
|
||
|
||
if not self.backup_manager:
|
||
result["message"] = "❌ Backup manager not enabled"
|
||
return result
|
||
|
||
backup_path = self.backup_manager.backup_dir / backup_filename
|
||
if not backup_path.exists():
|
||
result["message"] = f"❌ Backup file not found: {backup_filename}"
|
||
return result
|
||
|
||
# Create backup of current state before restore
|
||
if self.config_path.exists():
|
||
current_backup = self.backup_manager.create_backup(self.config_path)
|
||
if current_backup:
|
||
print(f"💾 Created pre-restore backup: {current_backup.name}", file=sys.stderr)
|
||
|
||
# Perform restore
|
||
if self.backup_manager.restore_backup(backup_path, self.config_path):
|
||
# Reload the teams data
|
||
self.load()
|
||
|
||
# Log audit trail
|
||
if self.enable_audit and self.audit_logger:
|
||
self.audit_logger.log_action(
|
||
"restore_backup",
|
||
{
|
||
"backup_file": backup_filename,
|
||
"restored_path": str(self.config_path),
|
||
"team_count": len(self.teams)
|
||
},
|
||
self.user_context
|
||
)
|
||
|
||
result["success"] = True
|
||
result["message"] = f"✅ Successfully restored from {backup_filename}"
|
||
result["team_count"] = len(self.teams)
|
||
else:
|
||
result["message"] = f"❌ Failed to restore from {backup_filename}"
|
||
|
||
return result
|
||
|
||
def query_audit(self, **filters) -> List[Dict[str, Any]]:
|
||
"""Query the audit log.
|
||
|
||
Args:
|
||
**filters: Optional filters like user, action, team_id, start_time, end_time, limit
|
||
|
||
Returns:
|
||
List of audit entries
|
||
"""
|
||
if not self.audit_logger:
|
||
return []
|
||
return self.audit_logger.query_audit_log(**filters)
|
||
|
||
def get_recent_audit(self, count: int = 10) -> List[Dict[str, Any]]:
|
||
"""Get recent audit entries.
|
||
|
||
Args:
|
||
count: Number of entries to return
|
||
|
||
Returns:
|
||
List of recent audit entries
|
||
"""
|
||
if not self.audit_logger:
|
||
return []
|
||
return self.audit_logger.get_recent_actions(count)
|
||
|
||
def get_team_history(self, team_id: int,
|
||
start_date: Optional[datetime] = None,
|
||
end_date: Optional[datetime] = None) -> List[Dict[str, Any]]:
|
||
"""Get history of changes for a specific team.
|
||
|
||
FUNC-011: Team history view with date range filtering.
|
||
|
||
Args:
|
||
team_id: The team ID to get history for
|
||
start_date: Optional start date filter
|
||
end_date: Optional end date filter
|
||
|
||
Returns:
|
||
List of audit entries for the team
|
||
"""
|
||
if not self.audit_logger:
|
||
return []
|
||
|
||
entries = self.audit_logger.query_audit_log(
|
||
team_id=team_id,
|
||
start_time=start_date,
|
||
end_time=end_date,
|
||
limit=1000
|
||
)
|
||
|
||
self.logger.info("team_history_queried", {
|
||
"team_id": team_id,
|
||
"start_date": start_date.isoformat() if start_date else None,
|
||
"end_date": end_date.isoformat() if end_date else None,
|
||
"entry_count": len(entries)
|
||
})
|
||
|
||
return entries
|
||
|
||
def get_project_timeline(self,
|
||
start_date: Optional[datetime] = None,
|
||
end_date: Optional[datetime] = None) -> List[Dict[str, Any]]:
|
||
"""Get timeline of all project events.
|
||
|
||
FUNC-011: Project timeline view with date range filtering.
|
||
|
||
Args:
|
||
start_date: Optional start date filter
|
||
end_date: Optional end date filter
|
||
|
||
Returns:
|
||
List of all project events sorted by timestamp
|
||
"""
|
||
if not self.audit_logger:
|
||
return []
|
||
|
||
entries = self.audit_logger.query_audit_log(
|
||
start_time=start_date,
|
||
end_time=end_date,
|
||
limit=1000
|
||
)
|
||
|
||
self.logger.info("project_timeline_queried", {
|
||
"project": self.project_name,
|
||
"start_date": start_date.isoformat() if start_date else None,
|
||
"end_date": end_date.isoformat() if end_date else None,
|
||
"entry_count": len(entries)
|
||
})
|
||
|
||
return entries
|
||
|
||
def health_check(self) -> dict:
|
||
"""Perform health check on team manager.
|
||
|
||
OPS-003: Checks Python backend status and file system access.
|
||
|
||
Returns:
|
||
dict with health status information
|
||
"""
|
||
health = {
|
||
"status": "healthy",
|
||
"checks": {},
|
||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||
"version": "1.0.0"
|
||
}
|
||
|
||
# Check 1: Python environment
|
||
try:
|
||
import sys
|
||
health["checks"]["python"] = {
|
||
"status": "pass",
|
||
"version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||
}
|
||
except Exception as e:
|
||
health["checks"]["python"] = {
|
||
"status": "fail",
|
||
"error": str(e)
|
||
}
|
||
health["status"] = "unhealthy"
|
||
|
||
# Check 2: File system access
|
||
try:
|
||
import tempfile
|
||
test_dir = Path(tempfile.gettempdir()) / "team_manager_health"
|
||
test_dir.mkdir(exist_ok=True)
|
||
test_file = test_dir / ".health_check"
|
||
test_file.write_text("ok")
|
||
content = test_file.read_text()
|
||
test_file.unlink()
|
||
test_dir.rmdir()
|
||
|
||
if content == "ok":
|
||
health["checks"]["filesystem"] = {
|
||
"status": "pass",
|
||
"writable": True
|
||
}
|
||
else:
|
||
health["checks"]["filesystem"] = {
|
||
"status": "fail",
|
||
"error": "Read/write mismatch"
|
||
}
|
||
health["status"] = "unhealthy"
|
||
except Exception as e:
|
||
health["checks"]["filesystem"] = {
|
||
"status": "fail",
|
||
"error": str(e)
|
||
}
|
||
health["status"] = "unhealthy"
|
||
|
||
# Check 3: Config directory access
|
||
try:
|
||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||
health["checks"]["config_dir"] = {
|
||
"status": "pass",
|
||
"path": str(self.config_path.parent),
|
||
"accessible": True
|
||
}
|
||
except Exception as e:
|
||
health["checks"]["config_dir"] = {
|
||
"status": "fail",
|
||
"path": str(self.config_path.parent),
|
||
"error": str(e)
|
||
}
|
||
health["status"] = "unhealthy"
|
||
|
||
# Check 4: JSON serialization
|
||
try:
|
||
test_data = {"test": True, "timestamp": datetime.utcnow().isoformat()}
|
||
json.dumps(test_data)
|
||
health["checks"]["json"] = {
|
||
"status": "pass"
|
||
}
|
||
except Exception as e:
|
||
health["checks"]["json"] = {
|
||
"status": "fail",
|
||
"error": str(e)
|
||
}
|
||
health["status"] = "unhealthy"
|
||
|
||
return health
|
||
|
||
# FUNC-005: Batch operation wrappers
|
||
def import_csv_file(self, csv_path: Path, dry_run: bool = False) -> Dict[str, Any]:
|
||
"""Import role assignments from CSV file."""
|
||
return import_csv(self, csv_path, dry_run)
|
||
|
||
def export_csv_file(self, csv_path: Path) -> Dict[str, Any]:
|
||
"""Export role assignments to CSV file."""
|
||
return export_csv(self, csv_path)
|
||
|
||
def import_json_file(self, json_path: Path, dry_run: bool = False) -> Dict[str, Any]:
|
||
"""Import role assignments from JSON file."""
|
||
return import_json(self, json_path, dry_run)
|
||
|
||
def export_json_file(self, json_path: Path, pretty: bool = True) -> Dict[str, Any]:
|
||
"""Export project state to JSON file."""
|
||
return export_json(self, json_path, pretty)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Team Manager - Standardized Team Layout")
|
||
parser.add_argument("--project", required=True, help="Project name")
|
||
parser.add_argument("--request-id", help="Correlation ID for request tracing")
|
||
parser.add_argument("--test-mode", action="store_true", help="Run in test mode (skips authentication)")
|
||
|
||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||
|
||
# Init command
|
||
init_parser = subparsers.add_parser("init", help="Initialize new project")
|
||
|
||
# List command
|
||
list_parser = subparsers.add_parser("list", help="List teams")
|
||
list_parser.add_argument("--phase", help="Filter by phase")
|
||
|
||
# Query command (FUNC-006)
|
||
query_parser = subparsers.add_parser("query", help="Query teams with filters")
|
||
query_parser.add_argument("--status", choices=["not_started", "active", "completed", "blocked"],
|
||
help="Filter by team status")
|
||
query_parser.add_argument("--phase", help="Filter by phase")
|
||
query_parser.add_argument("--assignee", help="Filter by person assigned to any role")
|
||
query_parser.add_argument("--role", help="Filter by specific role name")
|
||
query_parser.add_argument("--format", choices=["table", "json"], default="table",
|
||
help="Output format (default: table)")
|
||
|
||
# Assign command
|
||
assign_parser = subparsers.add_parser("assign", help="Assign person to role")
|
||
assign_parser.add_argument("--team", type=int, required=True, help="Team ID")
|
||
assign_parser.add_argument("--role", required=True, help="Role name")
|
||
assign_parser.add_argument("--person", required=True, help="Person name")
|
||
|
||
# Unassign command
|
||
unassign_parser = subparsers.add_parser("unassign", help="Remove person from role")
|
||
unassign_parser.add_argument("--team", type=int, required=True, help="Team ID")
|
||
unassign_parser.add_argument("--role", required=True, help="Role name")
|
||
|
||
# Reassign command (FUNC-009)
|
||
reassign_parser = subparsers.add_parser("reassign", help="Reassign person from one role to another")
|
||
reassign_parser.add_argument("--team", type=int, required=True, help="Team ID")
|
||
reassign_parser.add_argument("--from-role", required=True, help="Role to move from")
|
||
reassign_parser.add_argument("--to-role", required=True, help="Role to move to")
|
||
reassign_parser.add_argument("--person", required=True, help="Person to reassign")
|
||
|
||
# Start command
|
||
start_parser = subparsers.add_parser("start", help="Start a team")
|
||
start_parser.add_argument("--team", type=int, required=True, help="Team ID")
|
||
start_parser.add_argument("--override", action="store_true", help="Override phase gate check (admin only)")
|
||
start_parser.add_argument("--reason", help="Reason for override (required with --override)")
|
||
|
||
# Complete command
|
||
complete_parser = subparsers.add_parser("complete", help="Complete a team")
|
||
complete_parser.add_argument("--team", type=int, required=True, help="Team ID")
|
||
|
||
# Status command
|
||
status_parser = subparsers.add_parser("status", help="Show phase status")
|
||
status_parser.add_argument("--phase", help="Phase name")
|
||
|
||
# Validate-size command
|
||
validate_size_parser = subparsers.add_parser("validate-size", help="Validate team sizes (4-6 members)")
|
||
validate_size_parser.add_argument("--team", type=int, help="Specific team ID to validate (optional)")
|
||
|
||
# Delete-team command
|
||
delete_team_parser = subparsers.add_parser("delete-team", help="Delete a specific team from the project")
|
||
delete_team_parser.add_argument("--team", type=int, required=True, help="Team ID to delete")
|
||
delete_team_parser.add_argument("--confirmed", action="store_true", help="Confirm deletion (required)")
|
||
|
||
# Delete-project command
|
||
delete_project_parser = subparsers.add_parser("delete-project", help="Delete the entire project")
|
||
delete_project_parser.add_argument("--confirmed", action="store_true", help="Confirm deletion (required)")
|
||
|
||
# SEC-007: Encrypt-project command
|
||
encrypt_parser = subparsers.add_parser("encrypt-project", help="Encrypt project data at rest")
|
||
|
||
# SEC-007: Decrypt-project command
|
||
decrypt_parser = subparsers.add_parser("decrypt-project", help="Decrypt project data")
|
||
|
||
# List-backups command (OPS-004)
|
||
list_backups_parser = subparsers.add_parser("list-backups", help="List available backups")
|
||
|
||
# Restore command (OPS-004)
|
||
restore_parser = subparsers.add_parser("restore", help="Restore from a backup")
|
||
restore_parser.add_argument("--backup", required=True, help="Backup filename to restore")
|
||
|
||
# FUNC-008: Reload-rules command
|
||
reload_rules_parser = subparsers.add_parser("reload-rules", help="Reload rules from rules.json")
|
||
|
||
# OPS-006: Migrate command
|
||
migrate_parser = subparsers.add_parser("migrate", help="Migrate project to current version")
|
||
|
||
# Audit command (SEC-008)
|
||
audit_parser = subparsers.add_parser("audit", help="Query audit log")
|
||
audit_parser.add_argument("--user", help="Filter by user")
|
||
audit_parser.add_argument("--action", help="Filter by action type")
|
||
audit_parser.add_argument("--team", type=int, help="Filter by team ID")
|
||
audit_parser.add_argument("--limit", type=int, default=20, help="Maximum entries to show (default: 20)")
|
||
audit_parser.add_argument("--recent", action="store_true", help="Show most recent entries")
|
||
|
||
# Team history command (FUNC-011)
|
||
history_parser = subparsers.add_parser("team-history", help="Show history for a team")
|
||
history_parser.add_argument("--team", type=int, required=True, help="Team ID")
|
||
history_parser.add_argument("--start-date", help="Start date (ISO format: YYYY-MM-DD)")
|
||
history_parser.add_argument("--end-date", help="End date (ISO format: YYYY-MM-DD)")
|
||
history_parser.add_argument("--format", choices=["table", "json"], default="table", help="Output format")
|
||
|
||
# Project timeline command (FUNC-011)
|
||
timeline_parser = subparsers.add_parser("project-timeline", help="Show timeline of all project events")
|
||
timeline_parser.add_argument("--start-date", help="Start date (ISO format: YYYY-MM-DD)")
|
||
timeline_parser.add_argument("--end-date", help="End date (ISO format: YYYY-MM-DD)")
|
||
timeline_parser.add_argument("--format", choices=["table", "json"], default="table", help="Output format")
|
||
|
||
# Health command (OPS-003)
|
||
health_parser = subparsers.add_parser("health", help="Check team manager health status")
|
||
|
||
# OPS-008: Performance report command
|
||
perf_parser = subparsers.add_parser("performance-report", help="Show performance metrics report")
|
||
perf_parser.add_argument("--days", type=int, default=7, help="Number of days to include (default: 7)")
|
||
perf_parser.add_argument("--operation", help="Filter by operation type")
|
||
perf_parser.add_argument("--export", choices=["json", "csv"], help="Export to file format")
|
||
perf_parser.add_argument("--output", help="Output file path for export")
|
||
|
||
# FUNC-005: Batch operations commands
|
||
# Import CSV command
|
||
import_csv_parser = subparsers.add_parser("import-csv", help="Import role assignments from CSV")
|
||
import_csv_parser.add_argument("--file", required=True, help="Path to CSV file")
|
||
import_csv_parser.add_argument("--dry-run", action="store_true", help="Validate without making changes")
|
||
|
||
# Export CSV command
|
||
export_csv_parser = subparsers.add_parser("export-csv", help="Export role assignments to CSV")
|
||
export_csv_parser.add_argument("--file", required=True, help="Path to output CSV file")
|
||
|
||
# Import JSON command
|
||
import_json_parser = subparsers.add_parser("import-json", help="Import role assignments from JSON")
|
||
import_json_parser.add_argument("--file", required=True, help="Path to JSON file")
|
||
import_json_parser.add_argument("--dry-run", action="store_true", help="Validate without making changes")
|
||
|
||
# Export JSON command
|
||
export_json_parser = subparsers.add_parser("export-json", help="Export project state to JSON")
|
||
export_json_parser.add_argument("--file", required=True, help="Path to output JSON file")
|
||
export_json_parser.add_argument("--compact", action="store_true", help="Output compact JSON (no indentation)")
|
||
|
||
# Template commands
|
||
template_csv_parser = subparsers.add_parser("template-csv", help="Create CSV template for bulk assignments")
|
||
template_csv_parser.add_argument("--file", default="assignments_template.csv", help="Output file path")
|
||
|
||
template_json_parser = subparsers.add_parser("template-json", help="Create JSON template for bulk assignments")
|
||
template_json_parser.add_argument("--file", default="assignments_template.json", help="Output file path")
|
||
|
||
args = parser.parse_args()
|
||
|
||
# Suppress deprecation warnings in test mode for cleaner output
|
||
if args.test_mode:
|
||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||
|
||
# Validate project name to prevent command injection
|
||
validate_project_name(args.project)
|
||
|
||
# Create logger for main and generate request_id if not provided
|
||
request_id = args.request_id or f"tm-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{os.getpid()}"
|
||
cli_logger = StructuredLogger("team_manager_cli", request_id)
|
||
cli_logger.info("cli_start", {"command": args.command, "project": args.project})
|
||
|
||
# SEC-006: Handle SecurityError from path validation
|
||
try:
|
||
manager = TeamManager(args.project, test_mode=args.test_mode)
|
||
except SecurityError as e:
|
||
cli_logger.error("security_error", {"error": str(e)})
|
||
print(f"🔒 Security error: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
# SEC-005: Handle RateLimitExceeded
|
||
except RateLimitExceeded as e:
|
||
retry_msg = f" Retry after {e.retry_after}s." if e.retry_after else ""
|
||
cli_logger.error("rate_limit_exceeded", {"error": str(e), "retry_after": e.retry_after})
|
||
print(f"⏱️ Rate limit exceeded.{retry_msg}", file=sys.stderr)
|
||
sys.exit(429)
|
||
|
||
if args.command == "init":
|
||
manager.initialize_project()
|
||
print(f"\nTeams configuration saved to: {manager.config_path}")
|
||
|
||
elif args.command in ["list", "assign", "unassign", "start", "complete", "status", "validate-size", "delete-team", "delete-project", "list-backups", "restore", "audit", "import-csv", "export-csv", "import-json", "export-json"]:
|
||
if args.command in ["delete-team", "delete-project"]:
|
||
# For delete commands, project may not exist yet (delete-project)
|
||
if args.command == "delete-team" and not manager.load():
|
||
print(f"❌ Project '{args.project}' not found.")
|
||
sys.exit(1)
|
||
if args.command == "delete-project":
|
||
# Try to load but don't fail if file doesn't exist
|
||
manager.load()
|
||
else:
|
||
if not manager.load():
|
||
print(f"❌ Project '{args.project}' not found. Run: team_manager.py --project {args.project} init")
|
||
sys.exit(1)
|
||
|
||
if args.command == "list":
|
||
manager.list_teams(args.phase)
|
||
|
||
elif args.command == "query":
|
||
results = manager.query_teams(
|
||
status=args.status,
|
||
phase=args.phase,
|
||
assignee=args.assignee,
|
||
role_name=args.role
|
||
)
|
||
if args.format == "json":
|
||
print(json.dumps(results, indent=2))
|
||
else:
|
||
# Table format
|
||
if not results:
|
||
print("No teams match the specified filters.")
|
||
else:
|
||
print(f"\nFound {len(results)} team(s) matching filters:\n")
|
||
for team in results:
|
||
print(f"Team {team['id']}: {team['name']}")
|
||
print(f" Phase: {team['phase']}")
|
||
print(f" Status: {team['status']}")
|
||
print(f" Assigned: {team['assigned_count']}/{team['total_roles']}")
|
||
if team['roles']:
|
||
print(" Matching Roles:")
|
||
for role in team['roles']:
|
||
assignee = role['assigned_to'] or "(unassigned)"
|
||
print(f" - {role['name']}: {assignee}")
|
||
print()
|
||
|
||
elif args.command == "assign":
|
||
if manager.assign_role(args.team, args.role, args.person):
|
||
print(f"✅ Assigned {args.person} to {args.role} in Team {args.team}")
|
||
|
||
elif args.command == "unassign":
|
||
manager.unassign_role(args.team, args.role)
|
||
|
||
elif args.command == "reassign":
|
||
manager.reassign_role(args.team, args.from_role, args.to_role, args.person)
|
||
|
||
elif args.command == "start":
|
||
manager.start_team(args.team, override=args.override, reason=args.reason)
|
||
|
||
elif args.command == "complete":
|
||
manager.complete_team(args.team)
|
||
|
||
elif args.command == "status":
|
||
if args.phase:
|
||
status = manager.get_phase_status(args.phase)
|
||
print(f"\n{status['phase']}")
|
||
print(f" Progress: {status['progress_pct']:.0f}%")
|
||
print(f" Teams: {status['completed']}/{status['total_teams']} complete")
|
||
print(f" Active: {status['active']}, Not started: {status['not_started']}")
|
||
else:
|
||
# Show all phases
|
||
phases = set(t.phase for t in manager.teams.values())
|
||
for phase in sorted(phases, key=lambda p: p.split(":")[0]):
|
||
status = manager.get_phase_status(phase)
|
||
print(f"\n{status['phase']}: {status['progress_pct']:.0f}% complete")
|
||
|
||
elif args.command == "validate-size":
|
||
results = manager.validate_team_size(args.team)
|
||
if results["valid"]:
|
||
print(f"✅ All {results['teams_checked']} teams have valid size (4-6 members)")
|
||
sys.exit(0)
|
||
else:
|
||
print(f"❌ Team size violations found:")
|
||
for violation in results["violations"]:
|
||
print(f" {violation['message']}")
|
||
sys.exit(1)
|
||
|
||
elif args.command == "delete-team":
|
||
result = manager.delete_team(args.team, confirmed=args.confirmed)
|
||
print(result["message"])
|
||
if not result["success"] and not result["requires_confirmation"]:
|
||
sys.exit(1)
|
||
|
||
elif args.command == "delete-project":
|
||
result = manager.delete_project(confirmed=args.confirmed)
|
||
print(result["message"])
|
||
if not result["success"] and not result["requires_confirmation"]:
|
||
sys.exit(1)
|
||
|
||
elif args.command == "list-backups":
|
||
backups = manager.list_backups()
|
||
if backups:
|
||
print(f"\n📦 Available backups for '{args.project}':")
|
||
print(f"{'Filename':<50} {'Size':>10} {'Created At'}")
|
||
print("-" * 90)
|
||
for backup in backups:
|
||
size_kb = backup["size_bytes"] / 1024
|
||
print(f"{backup['filename']:<50} {size_kb:>9.1f}KB {backup['created_at']}")
|
||
print(f"\nTotal backups: {len(backups)}")
|
||
else:
|
||
print(f"ℹ️ No backups found for '{args.project}'")
|
||
|
||
elif args.command == "restore":
|
||
result = manager.restore_backup(args.backup)
|
||
print(result["message"])
|
||
if result["success"]:
|
||
print(f"\n📊 Project now has {result.get('team_count', 0)} team(s)")
|
||
else:
|
||
sys.exit(1)
|
||
|
||
elif args.command == "audit":
|
||
if args.recent:
|
||
entries = manager.get_recent_audit(args.limit)
|
||
else:
|
||
filters = {"limit": args.limit}
|
||
if args.user:
|
||
filters["user"] = args.user
|
||
if args.action:
|
||
filters["action"] = args.action
|
||
if args.team:
|
||
filters["team_id"] = args.team
|
||
entries = manager.query_audit(**filters)
|
||
|
||
if entries:
|
||
print(f"\n📋 Audit log entries for '{args.project}':")
|
||
print(f"{'Timestamp':<25} {'User':<15} {'Action':<20} {'Details'}")
|
||
print("-" * 100)
|
||
for entry in entries:
|
||
ts = entry["timestamp"].replace("T", " ").replace("Z", "")[:19]
|
||
user = entry.get("user", "unknown")[:14]
|
||
action = entry.get("action", "unknown")[:19]
|
||
details = json.dumps(entry.get("details", {}))[:50]
|
||
print(f"{ts:<25} {user:<15} {action:<20} {details}")
|
||
print(f"\nTotal entries: {len(entries)}")
|
||
else:
|
||
print(f"ℹ️ No audit entries found for '{args.project}'")
|
||
|
||
elif args.command == "team-history":
|
||
from datetime import datetime as dt
|
||
start_date = None
|
||
end_date = None
|
||
if args.start_date:
|
||
start_date = dt.fromisoformat(args.start_date)
|
||
if args.end_date:
|
||
end_date = dt.fromisoformat(args.end_date)
|
||
|
||
entries = manager.get_team_history(args.team, start_date, end_date)
|
||
|
||
if args.format == "json":
|
||
print(json.dumps(entries, indent=2))
|
||
else:
|
||
if entries:
|
||
team = manager.teams.get(args.team)
|
||
team_name = team.name if team else f"Team {args.team}"
|
||
print(f"\n📜 History for {team_name}:")
|
||
print(f"{'Timestamp':<25} {'Action':<20} {'Details'}")
|
||
print("-" * 80)
|
||
for entry in entries:
|
||
ts = entry["timestamp"].replace("T", " ").replace("Z", "")[:19]
|
||
action = entry.get("action", "unknown")[:19]
|
||
details = json.dumps(entry.get("details", {}))[:40]
|
||
print(f"{ts:<25} {action:<20} {details}")
|
||
print(f"\nTotal entries: {len(entries)}")
|
||
else:
|
||
print(f"ℹ️ No history found for team {args.team}")
|
||
|
||
elif args.command == "project-timeline":
|
||
from datetime import datetime as dt
|
||
start_date = None
|
||
end_date = None
|
||
if args.start_date:
|
||
start_date = dt.fromisoformat(args.start_date)
|
||
if args.end_date:
|
||
end_date = dt.fromisoformat(args.end_date)
|
||
|
||
entries = manager.get_project_timeline(start_date, end_date)
|
||
|
||
if args.format == "json":
|
||
print(json.dumps(entries, indent=2))
|
||
else:
|
||
if entries:
|
||
print(f"\n📅 Project Timeline for '{args.project}':")
|
||
print(f"{'Timestamp':<25} {'User':<15} {'Action':<20} {'Team/Details'}")
|
||
print("-" * 90)
|
||
for entry in entries:
|
||
ts = entry["timestamp"].replace("T", " ").replace("Z", "")[:19]
|
||
user = entry.get("user", "unknown")[:14]
|
||
action = entry.get("action", "unknown")[:19]
|
||
details = entry.get("details", {})
|
||
team_info = f"Team {details.get('team_id', 'N/A')}"
|
||
print(f"{ts:<25} {user:<15} {action:<20} {team_info}")
|
||
print(f"\nTotal entries: {len(entries)}")
|
||
else:
|
||
print(f"ℹ️ No timeline entries found for '{args.project}'")
|
||
|
||
# FUNC-005: Batch operation handlers
|
||
elif args.command == "import-csv":
|
||
result = manager.import_csv_file(Path(args.file), dry_run=args.dry_run)
|
||
if result["dry_run"]:
|
||
print(f"🔍 Dry run results for {args.file}:")
|
||
else:
|
||
print(f"📥 Imported from {args.file}:")
|
||
print(f" Success: {result['success']}")
|
||
print(f" Imported: {result['imported']}")
|
||
print(f" Skipped: {result['skipped']}")
|
||
if result["errors"]:
|
||
print(f" Errors: {len(result['errors'])}")
|
||
for error in result["errors"][:5]: # Show first 5 errors
|
||
print(f" Row {error.get('row', 'N/A')}: {error.get('error', 'Unknown error')}")
|
||
if len(result["errors"]) > 5:
|
||
print(f" ... and {len(result['errors']) - 5} more errors")
|
||
if not result["success"] and not result["dry_run"]:
|
||
sys.exit(1)
|
||
|
||
elif args.command == "export-csv":
|
||
result = manager.export_csv_file(Path(args.file))
|
||
if result["success"]:
|
||
print(f"✅ Exported {result['exported']} roles to {result['file_path']}")
|
||
else:
|
||
print(f"❌ Export failed: {result['errors']}")
|
||
sys.exit(1)
|
||
|
||
elif args.command == "import-json":
|
||
result = manager.import_json_file(Path(args.file), dry_run=args.dry_run)
|
||
if result["dry_run"]:
|
||
print(f"🔍 Dry run results for {args.file}:")
|
||
else:
|
||
print(f"📥 Imported from {args.file}:")
|
||
print(f" Success: {result['success']}")
|
||
print(f" Imported: {result['imported']}")
|
||
print(f" Skipped: {result['skipped']}")
|
||
if result["errors"]:
|
||
print(f" Errors: {len(result['errors'])}")
|
||
for error in result["errors"][:5]:
|
||
idx = error.get('index', 'N/A')
|
||
err_msg = error.get('error', 'Unknown error')
|
||
print(f" Entry {idx}: {err_msg}")
|
||
if len(result["errors"]) > 5:
|
||
print(f" ... and {len(result['errors']) - 5} more errors")
|
||
if not result["success"] and not result["dry_run"]:
|
||
sys.exit(1)
|
||
|
||
elif args.command == "export-json":
|
||
pretty = not args.compact
|
||
result = manager.export_json_file(Path(args.file), pretty=pretty)
|
||
if result["success"]:
|
||
print(f"✅ Exported {result['team_count']} teams to {result['file_path']}")
|
||
else:
|
||
print(f"❌ Export failed: {result['errors']}")
|
||
sys.exit(1)
|
||
|
||
elif args.command in ["template-csv", "template-json"]:
|
||
# Template commands don't require project to exist
|
||
if args.command == "template-csv":
|
||
result = create_csv_template(Path(args.file))
|
||
if result["success"]:
|
||
print(f"✅ Created CSV template: {result['file_path']}")
|
||
print(" Edit this file and run: team_manager.py --project <name> import-csv --file " + args.file)
|
||
else:
|
||
print(f"❌ Failed to create template: {result['errors']}")
|
||
sys.exit(1)
|
||
elif args.command == "template-json":
|
||
result = create_json_template(Path(args.file))
|
||
if result["success"]:
|
||
print(f"✅ Created JSON template: {result['file_path']}")
|
||
print(" Edit this file and run: team_manager.py --project <name> import-json --file " + args.file)
|
||
else:
|
||
print(f"❌ Failed to create template: {result['errors']}")
|
||
sys.exit(1)
|
||
|
||
elif args.command == "reload-rules":
|
||
# FUNC-008: Reload rules
|
||
reload_rules_cmd()
|
||
|
||
elif args.command == "migrate":
|
||
# OPS-006: Migration check and run
|
||
status = manager.migration_manager.get_migration_status()
|
||
if status["status"] == "needs_migration":
|
||
print(f"🔄 Project '{args.project}' needs migration:")
|
||
print(f" Current: v{status['current_version']}")
|
||
print(f" Target: v{status['target_version']}")
|
||
# Load will trigger migration
|
||
manager.load()
|
||
print(f"✅ Migration complete")
|
||
elif status["status"] == "current":
|
||
print(f"✅ Project '{args.project}' is at current version (v{status['current_version']})")
|
||
elif status["status"] == "not_found":
|
||
print(f"❌ Project '{args.project}' not found")
|
||
sys.exit(1)
|
||
else:
|
||
print(f"❌ Error: {status.get('error', 'Unknown error')}")
|
||
sys.exit(1)
|
||
|
||
elif args.command == "health":
|
||
# OPS-003: Health check - doesn't require project to exist
|
||
health = manager.health_check()
|
||
# FUNC-008: Add rules check
|
||
try:
|
||
loader = get_rules_loader()
|
||
health["checks"]["rules"] = {
|
||
"status": "pass",
|
||
"rules_path": str(loader.rules_path),
|
||
"rules_loaded": bool(loader.rules)
|
||
}
|
||
except Exception as e:
|
||
health["checks"]["rules"] = {
|
||
"status": "fail",
|
||
"error": str(e)
|
||
}
|
||
health["status"] = "unhealthy"
|
||
print(json.dumps(health, indent=2))
|
||
if health["status"] != "healthy":
|
||
sys.exit(1)
|
||
|
||
|
||
elif args.command == "performance-report":
|
||
# OPS-008: Performance metrics report
|
||
if args.export and args.output:
|
||
success = manager.performance_metrics.export_report(
|
||
Path(args.output), format=args.export, days=args.days
|
||
)
|
||
if success:
|
||
print(f"✅ Performance report exported to {args.output}")
|
||
else:
|
||
sys.exit(1)
|
||
else:
|
||
if args.operation:
|
||
stats = manager.performance_metrics.get_operation_stats(
|
||
operation=args.operation, since=datetime.utcnow() - timedelta(days=args.days)
|
||
)
|
||
print(f"\n📊 Performance Report: {args.operation}")
|
||
print(f"{'='*50}")
|
||
else:
|
||
stats = manager.performance_metrics.get_report(days=args.days)
|
||
print(f"\n📊 Performance Report (last {args.days} days)")
|
||
print(f"{'='*50}")
|
||
print(f"Project: {stats['project']}")
|
||
print(f"Generated: {stats['generated_at']}")
|
||
print()
|
||
# Overall stats
|
||
overall = stats['overall']
|
||
print(f"Overall Operations: {overall['count']}")
|
||
print(f" Success: {overall['success_count']} ({overall['success_rate']}%)")
|
||
print(f" Failures: {overall['failure_count']} ({overall['error_rate']}%)")
|
||
if 'duration_stats' in overall:
|
||
ds = overall['duration_stats']
|
||
print(f"\nDuration Statistics:")
|
||
print(f" Average: {ds['avg_ms']}ms")
|
||
print(f" Median: {ds['median_ms']}ms")
|
||
print(f" Min: {ds['min_ms']}ms")
|
||
print(f" Max: {ds['max_ms']}ms")
|
||
# By operation
|
||
if 'by_operation' in stats and stats['by_operation']:
|
||
print(f"\nBy Operation:")
|
||
for op, op_stats in stats['by_operation'].items():
|
||
if op_stats['count'] > 0:
|
||
print(f" {op}: {op_stats['count']} ops, avg {op_stats.get('duration_stats', {}).get('avg_ms', 'N/A')}ms")
|
||
if args.export == "json" and not args.output:
|
||
print(json.dumps(stats, indent=2))
|
||
|
||
elif args.command in ["encrypt-project", "decrypt-project"]:
|
||
# SEC-007: Encryption/Decryption commands
|
||
if not manager.encryption_manager.enabled:
|
||
print("❌ Encryption not enabled. Set TEAM_ENCRYPTION_KEY environment variable.")
|
||
sys.exit(1)
|
||
|
||
if not manager.config_path.exists():
|
||
print(f"❌ Project '{args.project}' not found.")
|
||
sys.exit(1)
|
||
|
||
# Load current data
|
||
with open(manager.config_path, 'r') as f:
|
||
data = json.load(f)
|
||
|
||
# Define sensitive fields to encrypt/decrypt
|
||
sensitive_fields = ["assigned_to", "assignee", "person", "user", "user_id"]
|
||
encrypted_count = 0
|
||
|
||
def process_encrypted_value(value, encrypt):
|
||
"""Process a potentially encrypted value."""
|
||
if not isinstance(value, str):
|
||
return value, 0
|
||
if encrypt:
|
||
if value.startswith('gAAAA'):
|
||
return value, 0
|
||
return manager.encryption_manager.encrypt(value), 1
|
||
else:
|
||
if not value.startswith('gAAAA'):
|
||
return value, 0
|
||
return manager.encryption_manager.decrypt(value), 1
|
||
|
||
def process_dict(d, encrypt):
|
||
"""Process a dictionary recursively."""
|
||
count = 0
|
||
for key, value in d.items():
|
||
if key in sensitive_fields and isinstance(value, str):
|
||
d[key], c = process_encrypted_value(value, encrypt)
|
||
count += c
|
||
elif isinstance(value, dict):
|
||
count += process_dict(value, encrypt)
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
if isinstance(item, dict):
|
||
count += process_dict(item, encrypt)
|
||
return count
|
||
|
||
encrypted_count = process_dict(data, args.command == "encrypt-project")
|
||
|
||
# Save back
|
||
with open(manager.config_path, 'w') as f:
|
||
json.dump(data, f, indent=2)
|
||
|
||
action = "Encrypted" if args.command == "encrypt-project" else "Decrypted"
|
||
print(f"✅ {action} {encrypted_count} sensitive fields in project '{args.project}'")
|
||
|
||
else:
|
||
parser.print_help()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|