feat(workflows): Add --epic and --mode flags to sprint-status-updater.py
- Add --epic flag to filter validation to specific epic (e.g., epic-1) - Add --mode flag with 'validate' and 'fix' options - Filter logic extracts epic number and matches story file prefixes - Enables per-epic validation for validate-all-epics workflow Part of: validate-all-epics workflow infrastructure
This commit is contained in:
parent
eecff22f91
commit
afaba40f80
|
|
@ -0,0 +1,421 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Sprint Status Updater - Robust YAML updater for sprint-status.yaml
|
||||||
|
|
||||||
|
Purpose: Update sprint-status.yaml entries while preserving:
|
||||||
|
- Comments
|
||||||
|
- Formatting
|
||||||
|
- Section structure
|
||||||
|
- Manual annotations
|
||||||
|
|
||||||
|
Created: 2026-01-02
|
||||||
|
Part of: Full Workflow Fix (Option C)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SprintStatusUpdater:
|
||||||
|
"""Updates sprint-status.yaml while preserving structure and comments"""
|
||||||
|
|
||||||
|
def __init__(self, sprint_status_path: str):
|
||||||
|
self.path = Path(sprint_status_path)
|
||||||
|
self.content = self.path.read_text()
|
||||||
|
self.lines = self.content.split('\n')
|
||||||
|
self.updates_applied = 0
|
||||||
|
|
||||||
|
def update_story_status(self, story_id: str, new_status: str, comment: str = None) -> bool:
|
||||||
|
"""
|
||||||
|
Update a single story's status in development_status section
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story identifier (e.g., "19-4a-inventory-service-test-coverage")
|
||||||
|
new_status: New status value (e.g., "done", "in-progress")
|
||||||
|
comment: Optional comment to append (e.g., "✅ COMPLETE 2026-01-02")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if update was applied, False if story not found or unchanged
|
||||||
|
"""
|
||||||
|
# Find the story line in development_status section
|
||||||
|
in_dev_status = False
|
||||||
|
story_line_idx = None
|
||||||
|
|
||||||
|
for idx, line in enumerate(self.lines):
|
||||||
|
if line.strip() == 'development_status:':
|
||||||
|
in_dev_status = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_dev_status:
|
||||||
|
# Check if we've left development_status section
|
||||||
|
if line and not line.startswith(' ') and not line.startswith('#'):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if this is our story
|
||||||
|
if line.startswith(' ') and story_id in line:
|
||||||
|
story_line_idx = idx
|
||||||
|
break
|
||||||
|
|
||||||
|
if story_line_idx is None:
|
||||||
|
# Story not found - need to add it
|
||||||
|
return self._add_story_entry(story_id, new_status, comment)
|
||||||
|
|
||||||
|
# Update existing line
|
||||||
|
current_line = self.lines[story_line_idx]
|
||||||
|
|
||||||
|
# Parse current line: " story-id: status # comment"
|
||||||
|
match = re.match(r'(\s+)([a-z0-9-]+):\s*(\S+)(.*)', current_line)
|
||||||
|
if not match:
|
||||||
|
print(f"WARNING: Could not parse line: {current_line}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
indent, current_story_id, current_status, existing_comment = match.groups()
|
||||||
|
|
||||||
|
# Check if update needed
|
||||||
|
if current_status == new_status:
|
||||||
|
return False # No change needed
|
||||||
|
|
||||||
|
# Build new line
|
||||||
|
if comment:
|
||||||
|
new_line = f"{indent}{story_id}: {new_status} # {comment}"
|
||||||
|
elif existing_comment:
|
||||||
|
# Preserve existing comment
|
||||||
|
new_line = f"{indent}{story_id}: {new_status}{existing_comment}"
|
||||||
|
else:
|
||||||
|
new_line = f"{indent}{story_id}: {new_status}"
|
||||||
|
|
||||||
|
self.lines[story_line_idx] = new_line
|
||||||
|
self.updates_applied += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _add_story_entry(self, story_id: str, status: str, comment: str = None) -> bool:
|
||||||
|
"""Add a new story entry to development_status section"""
|
||||||
|
# Find the epic this story belongs to
|
||||||
|
epic_match = re.match(r'^(\d+[a-z]?)-', story_id)
|
||||||
|
if not epic_match:
|
||||||
|
print(f"WARNING: Cannot determine epic for {story_id}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
epic_num = epic_match.group(1)
|
||||||
|
epic_key = f"epic-{epic_num}"
|
||||||
|
|
||||||
|
# Find where to insert the story (after its epic line)
|
||||||
|
in_dev_status = False
|
||||||
|
insert_idx = None
|
||||||
|
|
||||||
|
for idx, line in enumerate(self.lines):
|
||||||
|
if line.strip() == 'development_status:':
|
||||||
|
in_dev_status = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_dev_status:
|
||||||
|
# Look for the epic line
|
||||||
|
if line.strip().startswith(f"{epic_key}:"):
|
||||||
|
# Found the epic - insert after it
|
||||||
|
insert_idx = idx + 1
|
||||||
|
break
|
||||||
|
|
||||||
|
if insert_idx is None:
|
||||||
|
print(f"WARNING: Could not find epic {epic_key} in development_status", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Build new line
|
||||||
|
if comment:
|
||||||
|
new_line = f" {story_id}: {status} # {comment}"
|
||||||
|
else:
|
||||||
|
new_line = f" {story_id}: {status}"
|
||||||
|
|
||||||
|
# Insert the line
|
||||||
|
self.lines.insert(insert_idx, new_line)
|
||||||
|
self.updates_applied += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_epic_status(self, epic_key: str, new_status: str, comment: str = None) -> bool:
|
||||||
|
"""Update epic status line"""
|
||||||
|
in_dev_status = False
|
||||||
|
epic_line_idx = None
|
||||||
|
|
||||||
|
for idx, line in enumerate(self.lines):
|
||||||
|
if line.strip() == 'development_status:':
|
||||||
|
in_dev_status = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_dev_status:
|
||||||
|
if line and not line.startswith(' ') and not line.startswith('#'):
|
||||||
|
break
|
||||||
|
|
||||||
|
if line.strip().startswith(f"{epic_key}:"):
|
||||||
|
epic_line_idx = idx
|
||||||
|
break
|
||||||
|
|
||||||
|
if epic_line_idx is None:
|
||||||
|
print(f"WARNING: Epic {epic_key} not found", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Parse current line
|
||||||
|
current_line = self.lines[epic_line_idx]
|
||||||
|
match = re.match(r'(\s+)([a-z0-9-]+):\s*(\S+)(.*)', current_line)
|
||||||
|
if not match:
|
||||||
|
return False
|
||||||
|
|
||||||
|
indent, current_epic, current_status, existing_comment = match.groups()
|
||||||
|
|
||||||
|
if current_status == new_status:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Build new line
|
||||||
|
if comment:
|
||||||
|
new_line = f"{indent}{epic_key}: {new_status} # {comment}"
|
||||||
|
elif existing_comment:
|
||||||
|
new_line = f"{indent}{epic_key}: {new_status}{existing_comment}"
|
||||||
|
else:
|
||||||
|
new_line = f"{indent}{epic_key}: {new_status}"
|
||||||
|
|
||||||
|
self.lines[epic_line_idx] = new_line
|
||||||
|
self.updates_applied += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_verification_note(self):
|
||||||
|
"""Add verification timestamp to header"""
|
||||||
|
# Find and update last_verified line
|
||||||
|
for idx, line in enumerate(self.lines):
|
||||||
|
if line.startswith('# last_verified:'):
|
||||||
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S EST')
|
||||||
|
self.lines[idx] = f"# last_verified: {timestamp}"
|
||||||
|
break
|
||||||
|
|
||||||
|
def save(self, backup: bool = True) -> Path:
|
||||||
|
"""
|
||||||
|
Save updated content back to file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backup: If True, create backup before saving
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to backup file if created, otherwise original path
|
||||||
|
"""
|
||||||
|
if backup and self.updates_applied > 0:
|
||||||
|
backup_dir = Path('.sprint-status-backups')
|
||||||
|
backup_dir.mkdir(exist_ok=True)
|
||||||
|
backup_path = backup_dir / f"sprint-status-{datetime.now().strftime('%Y%m%d-%H%M%S')}.yaml"
|
||||||
|
backup_path.write_text(self.content)
|
||||||
|
print(f"✓ Backup created: {backup_path}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Write updated content
|
||||||
|
new_content = '\n'.join(self.lines)
|
||||||
|
self.path.write_text(new_content)
|
||||||
|
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
|
||||||
|
def scan_story_statuses(story_dir: str = "docs/sprint-artifacts") -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Scan all story files and extract EXPLICIT Status: fields
|
||||||
|
|
||||||
|
CRITICAL: Only returns stories that HAVE a Status: field.
|
||||||
|
If Status: field is missing, story is NOT included in results.
|
||||||
|
This prevents overwriting sprint-status.yaml with defaults.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping story_id -> normalized_status (ONLY for stories with explicit Status: field)
|
||||||
|
"""
|
||||||
|
story_dir_path = Path(story_dir)
|
||||||
|
story_files = list(story_dir_path.glob("*.md"))
|
||||||
|
|
||||||
|
STATUS_MAPPINGS = {
|
||||||
|
'done': 'done',
|
||||||
|
'complete': 'done',
|
||||||
|
'completed': 'done',
|
||||||
|
'in-progress': 'in-progress',
|
||||||
|
'in_progress': 'in-progress',
|
||||||
|
'review': 'review',
|
||||||
|
'ready-for-dev': 'ready-for-dev',
|
||||||
|
'ready_for_dev': 'ready-for-dev',
|
||||||
|
'pending': 'ready-for-dev',
|
||||||
|
'drafted': 'ready-for-dev',
|
||||||
|
'backlog': 'backlog',
|
||||||
|
'blocked': 'blocked',
|
||||||
|
'deferred': 'deferred',
|
||||||
|
'archived': 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
|
story_statuses = {}
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for story_file in story_files:
|
||||||
|
story_id = story_file.stem
|
||||||
|
|
||||||
|
# Skip special files
|
||||||
|
if (story_id.startswith('.') or
|
||||||
|
story_id.startswith('EPIC-') or
|
||||||
|
'COMPLETION' in story_id.upper() or
|
||||||
|
'SUMMARY' in story_id.upper() or
|
||||||
|
'REPORT' in story_id.upper() or
|
||||||
|
'README' in story_id.upper() or
|
||||||
|
'INDEX' in story_id.upper() or
|
||||||
|
'REVIEW' in story_id.upper() or
|
||||||
|
'AUDIT' in story_id.upper()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = story_file.read_text()
|
||||||
|
|
||||||
|
# Extract Status field
|
||||||
|
status_match = re.search(r'^Status:\s*(.+?)$', content, re.MULTILINE | re.IGNORECASE)
|
||||||
|
|
||||||
|
if status_match:
|
||||||
|
status = status_match.group(1).strip()
|
||||||
|
# Remove comments
|
||||||
|
status = re.sub(r'\s*#.*$', '', status).strip().lower()
|
||||||
|
|
||||||
|
# Normalize status
|
||||||
|
if status in STATUS_MAPPINGS:
|
||||||
|
normalized_status = STATUS_MAPPINGS[status]
|
||||||
|
elif 'done' in status or 'complete' in status:
|
||||||
|
normalized_status = 'done'
|
||||||
|
elif 'progress' in status:
|
||||||
|
normalized_status = 'in-progress'
|
||||||
|
elif 'review' in status:
|
||||||
|
normalized_status = 'review'
|
||||||
|
elif 'ready' in status:
|
||||||
|
normalized_status = 'ready-for-dev'
|
||||||
|
elif 'block' in status:
|
||||||
|
normalized_status = 'blocked'
|
||||||
|
elif 'defer' in status:
|
||||||
|
normalized_status = 'deferred'
|
||||||
|
elif 'archive' in status:
|
||||||
|
normalized_status = 'archived'
|
||||||
|
else:
|
||||||
|
normalized_status = 'ready-for-dev'
|
||||||
|
|
||||||
|
story_statuses[story_id] = normalized_status
|
||||||
|
else:
|
||||||
|
# CRITICAL FIX: No Status: field found
|
||||||
|
# Do NOT default to ready-for-dev - skip this story entirely
|
||||||
|
# This prevents overwriting sprint-status.yaml with incorrect defaults
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR parsing {story_id}: {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"✓ Found {len(story_statuses)} stories with explicit Status: fields", file=sys.stderr)
|
||||||
|
print(f"ℹ Skipped {skipped_count} stories without Status: fields (trust sprint-status.yaml)", file=sys.stderr)
|
||||||
|
|
||||||
|
return story_statuses
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for CLI usage"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='Update sprint-status.yaml from story files')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Show changes without applying')
|
||||||
|
parser.add_argument('--validate', action='store_true', help='Validate only (exit 1 if discrepancies)')
|
||||||
|
parser.add_argument('--sprint-status', default='docs/sprint-artifacts/sprint-status.yaml',
|
||||||
|
help='Path to sprint-status.yaml')
|
||||||
|
parser.add_argument('--story-dir', default='docs/sprint-artifacts',
|
||||||
|
help='Path to story files directory')
|
||||||
|
parser.add_argument('--epic', type=str, help='Validate specific epic only (e.g., epic-1)')
|
||||||
|
parser.add_argument('--mode', choices=['validate', 'fix'], default='validate',
|
||||||
|
help='Mode: validate (report only) or fix (apply updates)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Scan story files
|
||||||
|
print("Scanning story files...", file=sys.stderr)
|
||||||
|
story_statuses = scan_story_statuses(args.story_dir)
|
||||||
|
|
||||||
|
# Filter by epic if specified
|
||||||
|
if args.epic:
|
||||||
|
# Extract epic number from epic key (e.g., "epic-1" -> "1")
|
||||||
|
epic_match = re.match(r'epic-([0-9a-z-]+)', args.epic)
|
||||||
|
if epic_match:
|
||||||
|
epic_num = epic_match.group(1)
|
||||||
|
# Filter stories that start with this epic number
|
||||||
|
story_statuses = {k: v for k, v in story_statuses.items()
|
||||||
|
if k.startswith(f"{epic_num}-")}
|
||||||
|
print(f"✓ Filtered to {len(story_statuses)} stories for {args.epic}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"WARNING: Invalid epic format: {args.epic}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(f"✓ Scanned {len(story_statuses)} story files", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
# Load sprint-status.yaml
|
||||||
|
updater = SprintStatusUpdater(args.sprint_status)
|
||||||
|
|
||||||
|
# Find discrepancies
|
||||||
|
discrepancies = []
|
||||||
|
|
||||||
|
for story_id, new_status in story_statuses.items():
|
||||||
|
# Check current status in sprint-status.yaml
|
||||||
|
current_status = None
|
||||||
|
in_dev_status = False
|
||||||
|
|
||||||
|
for line in updater.lines:
|
||||||
|
if line.strip() == 'development_status:':
|
||||||
|
in_dev_status = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_dev_status and story_id in line:
|
||||||
|
match = re.match(r'\s+[a-z0-9-]+:\s*(\S+)', line)
|
||||||
|
if match:
|
||||||
|
current_status = match.group(1)
|
||||||
|
break
|
||||||
|
|
||||||
|
if current_status is None:
|
||||||
|
discrepancies.append((story_id, 'NOT-IN-FILE', new_status))
|
||||||
|
elif current_status != new_status:
|
||||||
|
discrepancies.append((story_id, current_status, new_status))
|
||||||
|
|
||||||
|
# Report
|
||||||
|
if not discrepancies:
|
||||||
|
print("✓ sprint-status.yaml is up to date!", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"⚠ Found {len(discrepancies)} discrepancies:", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
for story_id, old_status, new_status in discrepancies[:20]:
|
||||||
|
if old_status == 'NOT-IN-FILE':
|
||||||
|
print(f" [ADD] {story_id}: (not in file) → {new_status}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f" [UPDATE] {story_id}: {old_status} → {new_status}", file=sys.stderr)
|
||||||
|
|
||||||
|
if len(discrepancies) > 20:
|
||||||
|
print(f" ... and {len(discrepancies) - 20} more", file=sys.stderr)
|
||||||
|
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
# Handle mode parameter
|
||||||
|
if args.mode == 'validate' or args.validate:
|
||||||
|
print("✗ Validation failed - discrepancies found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("DRY RUN: Would update sprint-status.yaml", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Apply updates (--mode fix or default behavior)
|
||||||
|
print("Applying updates...", file=sys.stderr)
|
||||||
|
|
||||||
|
for story_id, old_status, new_status in discrepancies:
|
||||||
|
comment = f"Updated {datetime.now().strftime('%Y-%m-%d')}"
|
||||||
|
updater.update_story_status(story_id, new_status, comment)
|
||||||
|
|
||||||
|
# Add verification timestamp
|
||||||
|
updater.add_verification_note()
|
||||||
|
|
||||||
|
# Save
|
||||||
|
updater.save(backup=True)
|
||||||
|
|
||||||
|
print(f"✓ Applied {updater.updates_applied} updates", file=sys.stderr)
|
||||||
|
print(f"✓ Updated: {updater.path}", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue