BMAD-METHOD/scripts/lib/add-status-fields.py

254 lines
8.2 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

#!/usr/bin/env python3
"""
Add Status field to story files that are missing it.
Uses sprint-status.yaml as source of truth.
Smart path resolution - finds files regardless of working directory.
Features:
- Auto-detects project root and file locations
- Works from any working directory
- Explicit path overrides via CLI arguments
- Clear error messages if files not found
"""
import re
import sys
from pathlib import Path
from typing import Dict, Optional
def find_project_root() -> Path:
"""
Find project root by looking for .git directory or other markers.
Works from any subdirectory.
"""
current = Path.cwd()
# Try up to 10 levels up
for _ in range(10):
if (current / '.git').exists():
return current
if (current / '.claude').exists():
return current
current = current.parent
if current == current.parent: # Reached filesystem root
break
# Fallback to current working directory
return Path.cwd()
def find_story_dir(project_root: Optional[Path] = None) -> Path:
"""
Auto-detect story directory location.
Tries multiple possible paths and returns the first one that exists.
"""
if project_root is None:
project_root = find_project_root()
# Try paths in order of preference
candidates = [
project_root / "_bmad-output" / "implementation-artifacts" / "sprint-artifacts",
project_root / "docs" / "sprint-artifacts",
project_root / "sprint-artifacts",
Path.cwd() / "sprint-artifacts",
]
for candidate in candidates:
if candidate.exists():
return candidate
# If none found, suggest the most likely paths
print(f"ERROR: Could not find story directory.", file=sys.stderr)
print(f"Tried:", file=sys.stderr)
for candidate in candidates:
print(f" - {candidate}", file=sys.stderr)
raise FileNotFoundError("Story directory not found")
def find_sprint_status(project_root: Optional[Path] = None, story_dir: Optional[Path] = None) -> Path:
"""
Auto-detect sprint-status.yaml location.
Looks in the story directory or its parent.
"""
if story_dir is None:
story_dir = find_story_dir(project_root)
# Try paths in order of preference
candidates = [
story_dir / "sprint-status.yaml",
story_dir.parent / "sprint-status.yaml",
]
for candidate in candidates:
if candidate.exists():
return candidate
# If none found, suggest the most likely paths
print(f"ERROR: Could not find sprint-status.yaml", file=sys.stderr)
print(f"Tried:", file=sys.stderr)
for candidate in candidates:
print(f" - {candidate}", file=sys.stderr)
raise FileNotFoundError("sprint-status.yaml not found")
def load_sprint_status(path: Path) -> Dict[str, str]:
"""Load story statuses from sprint-status.yaml"""
if not path.exists():
raise FileNotFoundError(f"sprint-status.yaml not found at: {path}")
lines = path.read_text().split('\n')
statuses = {}
in_dev_status = False
for line in lines:
if 'development_status:' in line:
in_dev_status = True
continue
if in_dev_status:
# Check if we've left development_status section
if line.strip() and not line.startswith(' ') and not line.startswith('#'):
break
# Parse story line: " story-id: status # comment"
match = re.match(r' ([a-z0-9-]+):\s*(\S+)', line)
if match:
story_id, status = match.groups()
statuses[story_id] = status
return statuses
def add_status_to_story(story_file: Path, status: str) -> bool:
"""Add Status field to story file if missing"""
content = story_file.read_text()
# Check if Status field already exists (handles both "Status:" and "**Status:**")
if re.search(r'^\*?\*?Status:', content, re.MULTILINE | re.IGNORECASE):
return False # Already has Status field
# Find the first section after the title (usually ## Story or ## Description)
# Insert Status field before that
lines = content.split('\n')
# Find insertion point (after title, before first ## section)
insert_idx = None
for idx, line in enumerate(lines):
if line.startswith('# ') and idx == 0:
# Title line - keep looking
continue
if line.startswith('##'):
# Found first section - insert before it
insert_idx = idx
break
if insert_idx is None:
# No ## sections found, insert after title
insert_idx = 1
# Insert blank line, Status field, blank line
lines.insert(insert_idx, '')
lines.insert(insert_idx + 1, f'**Status:** {status}')
lines.insert(insert_idx + 2, '')
# Write back
story_file.write_text('\n'.join(lines))
return True
def main():
"""Main entry point for CLI usage"""
import argparse
parser = argparse.ArgumentParser(
description='Add Status field to story files that are missing it',
epilog='Path arguments are optional - script auto-detects locations. Use --story-dir and --sprint-status to override.'
)
parser.add_argument('--story-dir', default=None,
help='Path to story files directory (auto-detected if omitted)')
parser.add_argument('--sprint-status', default=None,
help='Path to sprint-status.yaml (auto-detected if omitted)')
args = parser.parse_args()
try:
# Auto-detect paths if not provided
project_root = find_project_root()
print(f"📁 Project root: {project_root}", file=sys.stderr)
if args.story_dir:
story_dir = Path(args.story_dir).resolve()
else:
story_dir = find_story_dir(project_root)
if args.sprint_status:
sprint_status_path = Path(args.sprint_status).resolve()
else:
sprint_status_path = find_sprint_status(project_root, story_dir)
print(f"📖 Story directory: {story_dir}", file=sys.stderr)
print(f"📋 Sprint status file: {sprint_status_path}", file=sys.stderr)
print("", file=sys.stderr)
# Load statuses from sprint-status.yaml
print("Loading sprint-status.yaml...", file=sys.stderr)
statuses = load_sprint_status(sprint_status_path)
print(f"✓ Found {len(statuses)} entries in sprint-status.yaml", file=sys.stderr)
print("", file=sys.stderr)
# Scan story files and add Status fields
print("Scanning story files...", file=sys.stderr)
added = 0
skipped = 0
missing = 0
for story_file in sorted(story_dir.glob("*.md")):
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
if story_id not in statuses:
print(f"⚠️ {story_id}: Not in sprint-status.yaml", file=sys.stderr)
missing += 1
continue
status = statuses[story_id]
if add_status_to_story(story_file, status):
print(f"{story_id}: Added Status: {status}", file=sys.stderr)
added += 1
else:
skipped += 1
print("", file=sys.stderr)
print(f"✅ Added Status field to {added} stories", file=sys.stderr)
print(f" Skipped {skipped} stories (already have Status)", file=sys.stderr)
if missing > 0:
print(f"⚠️ {missing} stories not in sprint-status.yaml", file=sys.stderr)
sys.exit(0)
except FileNotFoundError as e:
print(f"{e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"✗ Error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()