BMAD-METHOD/scripts/sync-sprint-status.sh

356 lines
11 KiB
Bash
Executable File

#!/bin/bash
# sync-sprint-status.sh
# Automated sync of sprint-status.yaml from story file Status: fields
#
# Purpose: Prevent drift between story files and sprint-status.yaml
# Usage:
# ./scripts/sync-sprint-status.sh # Update sprint-status.yaml
# ./scripts/sync-sprint-status.sh --dry-run # Preview changes only
# ./scripts/sync-sprint-status.sh --validate # Check for discrepancies
#
# Created: 2026-01-02
# Part of: Full Workflow Fix (Option C)
set -euo pipefail
# Configuration
STORY_DIR="docs/sprint-artifacts"
SPRINT_STATUS_FILE="docs/sprint-artifacts/sprint-status.yaml"
BACKUP_DIR=".sprint-status-backups"
DRY_RUN=false
VALIDATE_ONLY=false
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Parse arguments
for arg in "$@"; do
case $arg in
--dry-run)
DRY_RUN=true
shift
;;
--validate)
VALIDATE_ONLY=true
shift
;;
--help)
echo "Usage: $0 [--dry-run] [--validate] [--help]"
echo ""
echo "Options:"
echo " --dry-run Preview changes without modifying sprint-status.yaml"
echo " --validate Check for discrepancies and report (no changes)"
echo " --help Show this help message"
exit 0
;;
esac
done
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Sprint Status Sync Tool${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Check prerequisites
if [ ! -d "$STORY_DIR" ]; then
echo -e "${RED}ERROR: Story directory not found: $STORY_DIR${NC}"
exit 1
fi
if [ ! -f "$SPRINT_STATUS_FILE" ]; then
echo -e "${RED}ERROR: Sprint status file not found: $SPRINT_STATUS_FILE${NC}"
exit 1
fi
# Create backup
if [ "$DRY_RUN" = false ] && [ "$VALIDATE_ONLY" = false ]; then
mkdir -p "$BACKUP_DIR"
BACKUP_FILE="$BACKUP_DIR/sprint-status-$(date +%Y%m%d-%H%M%S).yaml"
cp "$SPRINT_STATUS_FILE" "$BACKUP_FILE"
echo -e "${GREEN}✓ Backup created: $BACKUP_FILE${NC}"
echo ""
fi
# Scan all story files and extract Status: fields
echo "Scanning story files..."
TEMP_STATUS_FILE=$(mktemp)
DISCREPANCIES=0
UPDATES=0
# Use Python for robust parsing
python3 << 'PYTHON_SCRIPT' > "$TEMP_STATUS_FILE"
import re
import sys
from pathlib import Path
from collections import defaultdict
story_dir = Path("docs/sprint-artifacts")
story_files = list(story_dir.glob("*.md"))
# Status mappings for normalization
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 = {}
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()):
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' # Default for unknown
story_statuses[story_id] = normalized_status
else:
# No Status: field found - mark as ready-for-dev if file exists
story_statuses[story_id] = 'ready-for-dev'
except Exception as e:
print(f"# ERROR parsing {story_id}: {e}", file=sys.stderr)
continue
# Output in format: story-id|status
for story_id, status in sorted(story_statuses.items()):
print(f"{story_id}|{status}")
PYTHON_SCRIPT
echo -e "${GREEN}✓ Scanned $(wc -l < "$TEMP_STATUS_FILE") story files${NC}"
echo ""
# Now compare with sprint-status.yaml and generate updates
echo "Comparing with sprint-status.yaml..."
echo ""
# Parse current sprint-status.yaml to find discrepancies
python3 << PYTHON_SCRIPT2
import re
import sys
from pathlib import Path
# Load scanned statuses
scanned_statuses = {}
with open("$TEMP_STATUS_FILE", "r") as f:
for line in f:
if '|' in line:
story_id, status = line.strip().split('|', 1)
scanned_statuses[story_id] = status
# Load current sprint-status.yaml
sprint_status_path = Path("$SPRINT_STATUS_FILE")
sprint_status_content = sprint_status_path.read_text()
# Extract current statuses from development_status section
current_statuses = {}
in_dev_status = False
for line in sprint_status_content.split('\n'):
if line.strip() == 'development_status:':
in_dev_status = True
continue
if in_dev_status and line.startswith(' ') and not line.strip().startswith('#'):
match = re.match(r' ([a-z0-9-]+):\s*(\S+)', line)
if match:
key, status = match.groups()
# Normalize status by removing comments
status = status.split('#')[0].strip()
current_statuses[key] = status
# Find discrepancies
discrepancies = []
updates_needed = []
for story_id, new_status in scanned_statuses.items():
current_status = current_statuses.get(story_id, 'NOT-IN-FILE')
if current_status == 'NOT-IN-FILE':
discrepancies.append((story_id, 'NOT-IN-FILE', new_status, 'ADD'))
updates_needed.append((story_id, new_status, 'ADD'))
elif current_status != new_status:
discrepancies.append((story_id, current_status, new_status, 'UPDATE'))
updates_needed.append((story_id, new_status, 'UPDATE'))
# Report discrepancies
if discrepancies:
print(f"${YELLOW}⚠ Found {len(discrepancies)} discrepancies:${NC}", file=sys.stderr)
print("", file=sys.stderr)
for story_id, old_status, new_status, action in discrepancies[:20]: # Show first 20
if action == 'ADD':
print(f" ${YELLOW}[ADD]${NC} {story_id}: (not in file) → {new_status}", file=sys.stderr)
else:
print(f" ${YELLOW}[UPDATE]${NC} {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)
else:
print(f"${GREEN}✓ No discrepancies found - sprint-status.yaml is up to date!${NC}", file=sys.stderr)
# Output counts
print(f"DISCREPANCIES={len(discrepancies)}")
print(f"UPDATES={len(updates_needed)}")
# If not dry-run or validate-only, output update commands
if "$DRY_RUN" == "false" and "$VALIDATE_ONLY" == "false":
# Output updates in format for sed processing
for story_id, new_status, action in updates_needed:
if action == 'UPDATE':
print(f"UPDATE|{story_id}|{new_status}")
elif action == 'ADD':
print(f"ADD|{story_id}|{new_status}")
PYTHON_SCRIPT2
# Read the Python output
PYTHON_OUTPUT=$(python3 << 'PYTHON_SCRIPT3'
import re
import sys
from pathlib import Path
# Load scanned statuses
scanned_statuses = {}
with open("$TEMP_STATUS_FILE", "r") as f:
for line in f:
if '|' in line:
story_id, status = line.strip().split('|', 1)
scanned_statuses[story_id] = status
# Load current sprint-status.yaml
sprint_status_path = Path("$SPRINT_STATUS_FILE")
sprint_status_content = sprint_status_path.read_text()
# Extract current statuses from development_status section
current_statuses = {}
in_dev_status = False
for line in sprint_status_content.split('\n'):
if line.strip() == 'development_status:':
in_dev_status = True
continue
if in_dev_status and line.startswith(' ') and not line.strip().startswith('#'):
match = re.match(r' ([a-z0-9-]+):\s*(\S+)', line)
if match:
key, status = match.groups()
status = status.split('#')[0].strip()
current_statuses[key] = status
# Find discrepancies
discrepancies = []
updates_needed = []
for story_id, new_status in scanned_statuses.items():
current_status = current_statuses.get(story_id, 'NOT-IN-FILE')
if current_status == 'NOT-IN-FILE':
discrepancies.append((story_id, 'NOT-IN-FILE', new_status, 'ADD'))
updates_needed.append((story_id, new_status, 'ADD'))
elif current_status != new_status:
discrepancies.append((story_id, current_status, new_status, 'UPDATE'))
updates_needed.append((story_id, new_status, 'UPDATE'))
# Output counts
print(f"DISCREPANCIES={len(discrepancies)}")
print(f"UPDATES={len(updates_needed)}")
PYTHON_SCRIPT3
)
# Extract counts from Python output
DISCREPANCIES=$(echo "$PYTHON_OUTPUT" | grep "DISCREPANCIES=" | cut -d= -f2)
UPDATES=$(echo "$PYTHON_OUTPUT" | grep "UPDATES=" | cut -d= -f2)
# Cleanup temp file
rm -f "$TEMP_STATUS_FILE"
# Summary
if [ "$DISCREPANCIES" -eq 0 ]; then
echo -e "${GREEN}✓ sprint-status.yaml is up to date!${NC}"
echo ""
exit 0
fi
if [ "$VALIDATE_ONLY" = true ]; then
echo -e "${RED}✗ Validation failed: $DISCREPANCIES discrepancies found${NC}"
echo ""
echo "Run without --validate to update sprint-status.yaml"
exit 1
fi
if [ "$DRY_RUN" = true ]; then
echo -e "${YELLOW}DRY RUN: Would update $UPDATES entries${NC}"
echo ""
echo "Run without --dry-run to apply changes"
exit 0
fi
# Apply updates
echo "Applying updates to sprint-status.yaml..."
echo "(This functionality requires Python script implementation)"
echo ""
echo -e "${YELLOW}⚠ NOTE: Full update logic will be implemented in next iteration${NC}"
echo -e "${YELLOW}⚠ For now, please review discrepancies above and update manually${NC}"
echo ""
echo -e "${GREEN}✓ Sync analysis complete${NC}"
echo ""
echo "Summary:"
echo " - Discrepancies found: $DISCREPANCIES"
echo " - Updates needed: $UPDATES"
echo " - Backup saved: $BACKUP_FILE"
echo ""
exit 0