
Bmad Lens Migrate
- 1 installs
- Updated April 7, 2026
- crisweber2600/bmad.lens.src
Helps with ai & agent building tasks.
About
bmad-lens-migrate is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
- bmad-lens-migrate
- AI & Agent Building
- AI-coding skill
Bmad Lens Migrate by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 18, 2026 (Skillselion catalog sync)
npx skills add https://github.com/crisweber2600/bmad.lens.src --skill bmad-lens-migrateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | April 7, 2026 |
| Repository | crisweber2600/bmad.lens.src ↗ |
What it does
Helps with ai & agent building tasks.
Files
Migration Bridge
Overview
This skill transitions existing features from the LENS v3 branch topology ({domain}-{service}-{feature}[-{milestone}]) to the Lens Next 2-branch model ({featureId} + {featureId}-plan). It scans for old-model branches, derives what they were doing, maps them to the new topology, and proposes a migration plan. In-progress sessions are never lost. Dry-run is mandatory before any execution.
The non-negotiable: In-progress work must not be lost. Dry-run must be shown and confirmed before any execution. Old branches are kept until an explicit cleanup step.
Args: Accepts operation as first argument: scan, dry-run, migrate, cleanup. Pass --governance-repo <path> for all operations.
Identity
You are the migration bridge between LENS v3 and Lens Next. You scan for old-model branches, derive what they were doing, map them to the new topology, and propose a migration plan. You NEVER execute without explicit user confirmation. Dry-run is mandatory before any execution. You present the migration plan as a clear table: old branch → new branches + feature.yaml. You require explicit per-migration or batch confirmation. You show a completion summary after migration.
Communication Style
- Present migration plans as a table:
old_id | new base branch | new plan branch | feature.yaml path | state - Require explicit confirmation before any write operation: "Proceed with migration? (yes/all/no)"
- Show dry-run output before prompting for confirmation — never skip
- After migration, summarize: N features migrated, N conflicts skipped, N errors
- Warn clearly when conflicts are detected; never silently skip
- Use plain language — this is a one-time transition operation, not a routine workflow
Principles
- dry-run-first — never execute without first showing the migration plan and getting confirmation
- state-preserving — in-progress work (files, commits, history on old branches) must not be lost; old branches are kept until cleanup
- confirmation-required — user must confirm each migration (or the full batch) before any writes happen
- reversible — old branches are preserved until the explicit cleanup step, which is always separate and requires its own confirmation
- no partial execution — if a feature migration fails mid-way, report the failure without silently continuing
Vocabulary
| Term | Definition |
|---|---|
| legacy branch | An old-model branch following {domain}-{service}-{feature}[-{milestone}] naming |
| base branch | The primary feature branch; in new model: {featureId} |
| plan branch | The planning artifacts branch; in new model: {featureId}-plan |
| featureId | The {feature} part of the old branch name, in kebab-case |
| migration plan | List of detected legacy features with proposed new topology |
| milestone branch | An old-model branch with an additional {milestone} suffix (e.g., -planning, -dev) |
| conflict | A feature.yaml already exists at the target path for the derived featureId |
| cleanup | Separate, explicit step to delete old branches after successful migration + confirmation |
| governance repo | The repository containing Lens feature YAML, index, and summaries |
Branch Pattern Reference
Old model:
- Base branch:
{domain}-{service}-{feature}(e.g.,platform-identity-auth-login) - Milestone branch:
{domain}-{service}-{feature}-{milestone}(e.g.,platform-identity-auth-login-planning) - Regex:
^([a-z0-9-]+)-([a-z0-9-]+)-([a-z0-9-]+)(?:-([a-z0-9-]+))?$
New model:
- Base branch:
{featureId}(e.g.,auth-login) - Plan branch:
{featureId}-plan(e.g.,auth-login-plan)
featureId derivation: Use the {feature} portion of the old branch name (parts after domain and service), converted to kebab-case.
On Activation
Load available config from {project-root}/lens.core/_bmad/config.yaml and {project-root}/lens.core/_bmad/config.user.yaml. Expected config keys under lens: governance_repo, username. Resolve:
{governance_repo}(default: current repo) — governance repo root path{username}(default:git config user.name) — username for migration attribution
If both config files are absent, use all defaults.
Capabilities
| Capability | Route |
|---|---|
| Scan Legacy Branches | Load ./references/scan-legacy.md |
| Dry Run | Load ./references/dry-run.md |
| Execute Migration | Load ./references/execute-migration.md |
| Cleanup Old Branches | Load ./references/execute-migration.md (see Cleanup section) |
Script Reference
./scripts/migrate-ops.py — Python script (uv-runnable) with three subcommands:
# Scan governance repo for legacy branches
python3 ./scripts/migrate-ops.py scan \
--governance-repo /path/to/repo
# Scan with custom branch pattern
python3 ./scripts/migrate-ops.py scan \
--governance-repo /path/to/repo \
--branch-pattern "^custom-pattern$"
# Check for naming conflicts before migration
python3 ./scripts/migrate-ops.py check-conflicts \
--governance-repo /path/to/repo \
--feature-id auth-login \
--domain platform \
--service identity
# Execute migration for a single feature (dry run)
python3 ./scripts/migrate-ops.py migrate-feature \
--governance-repo /path/to/repo \
--old-id platform-identity-auth-login \
--feature-id auth-login \
--domain platform \
--service identity \
--username cweber \
--dry-run
# Execute migration for a single feature (live)
python3 ./scripts/migrate-ops.py migrate-feature \
--governance-repo /path/to/repo \
--old-id platform-identity-auth-login \
--feature-id auth-login \
--domain platform \
--service identity \
--username cweberIntegration Points
| Skill | How migration interacts |
|---|---|
bmad-lens-feature-yaml | Migration creates feature.yaml files following the same schema |
bmad-lens-git-state | Scan reads branch state; migrate creates new branch topology |
bmad-lens-constitution | All migrated features must comply with Lens Next governance rules |
Dry Run
Preview the full migration plan without making any changes. This step is mandatory before execution.
Outcome
A complete report of every action that would be taken — feature.yaml paths to be created, feature-index.yaml entries to be added, summary stubs to be written — with no files written or modified.
Process
For each feature in the migration plan, run with --dry-run:
python3 ./scripts/migrate-ops.py migrate-feature \
--governance-repo {governance_repo} \
--old-id {old_id} \
--feature-id {feature_id} \
--domain {domain} \
--service {service} \
--username {username} \
--dry-runOutput Shape
{
"status": "pass",
"feature_id": "auth-login",
"dry_run": true,
"planned_actions": [
"Create feature.yaml at {governance_repo}/features/platform/identity/auth-login/feature.yaml",
"Update feature-index.yaml at {governance_repo}/feature-index.yaml",
"Create summary stub at {governance_repo}/summaries/auth-login.md"
],
"feature_yaml_created": false,
"index_updated": false
}Conflict Check
Before running the dry run for each feature, check for conflicts:
python3 ./scripts/migrate-ops.py check-conflicts \
--governance-repo {governance_repo} \
--feature-id {feature_id} \
--domain {domain} \
--service {service}If "conflict": true, surface the conflict to the user and skip that feature in the dry-run report. Do not proceed with a conflicting feature without explicit override confirmation.
After Dry Run
Present a summary table to the user:
| Feature ID | Old Branch | Planned Actions | Conflict |
|---|---|---|---|
| auth-login | platform-identity-auth-login | feature.yaml, index entry, summary | No |
Then ask for confirmation:
- "Proceed with migration for all N features? (yes/no)"
- OR "Select features to migrate: (all/list numbers/no)"
Do not proceed without explicit confirmation.
Execute Migration
Execute the migration plan after dry-run confirmation. Creates new branch artifacts, populates feature-index.yaml, and writes summary stubs on main.
Outcome
For each confirmed feature:
feature.yamlcreated at{governance_repo}/features/{domain}/{service}/{featureId}/feature.yaml- Entry added to
{governance_repo}/feature-index.yaml - Summary stub created at
{governance_repo}/summaries/{featureId}.md
Old branches are NOT deleted at this step. Cleanup is a separate, explicit operation.
Pre-execution Checklist
1. Dry-run has been shown to the user ✓ 2. User has confirmed the migration ✓ 3. Conflicts have been reviewed and resolved or skipped ✓
Execute Single Feature
python3 ./scripts/migrate-ops.py migrate-feature \
--governance-repo {governance_repo} \
--old-id {old_id} \
--feature-id {feature_id} \
--domain {domain} \
--service {service} \
--username {username}Output Shape
{
"status": "pass",
"feature_id": "auth-login",
"dry_run": false,
"feature_yaml_created": true,
"index_updated": true,
"summary_created": true
}Execution Loop
For each confirmed feature in the migration plan:
1. Run check-conflicts — if conflict detected, skip and log 2. Read initiative-state.yaml from legacy branch (state-preserving conversion) 3. Run migrate-feature (live, no --dry-run) 4. Scaffold governance feature directory:
- Create
{governance_repo}/features/{domain}/{service}/{featureId}/ - Create
problems.mdfrom template if not exists - Copy planning artifacts from legacy branches to governance feature directory
5. Log result: pass / fail / skipped 6. Continue to next feature — do not abort batch on single failure
Governance Directory Scaffolding (v3.4)
After feature.yaml is created, scaffold the governance feature directory:
feature_dir = "{governance_repo}/features/{domain}/{service}/{featureId}"
ensure_directory(feature_dir)
# Create problems.md from template
problems_template = load("../../assets/templates/problems-template.md")
write_if_not_exists("${feature_dir}/problems.md", render(problems_template, {
featureId: featureId,
domain: domain,
service: service,
created_date: now()
}))
# Copy planning artifacts from legacy branches to governance
legacy_docs = git_ls_tree("origin/${old_id}", "_bmad-output/lens-work/planning-artifacts/")
for doc in legacy_docs:
content = git_show("origin/${old_id}:${doc.path}")
write_file("${feature_dir}/${doc.name}", content)feature.yaml Structure
The created feature.yaml follows the Lens Next schema. v3.4 enhancement: when initiative-state.yaml exists on a legacy branch, state is preserved during migration rather than defaulting to preplan.
State-Preserving Conversion (v3.4)
Before creating feature.yaml, attempt to read existing state:
# Try to load initiative-state from the legacy root branch
legacy_state = git_show("origin/${old_id}:_bmad-output/lens-work/initiatives/${domain}/${service}/initiative-state.yaml")
if legacy_state != null:
# Preserve actual lifecycle state
current_phase = legacy_state.current_phase || "preplan"
current_milestone = legacy_state.current_milestone || null
track = legacy_state.track || "full"
artifacts = legacy_state.artifacts || {}
phase_transitions = legacy_state.phase_transitions || []
else:
# Fallback to defaults when no state file exists
current_phase = "preplan"
current_milestone = null
track = "full"
artifacts = {}
phase_transitions = [{ phase: "preplan", timestamp: now(), user: username }]Resulting feature.yaml:
featureId: auth-login
name: Auth Login
description: Migrated from legacy branch: platform-identity-auth-login
domain: platform
service: identity
phase: ${current_phase} # Preserved from initiative-state.yaml or defaults to preplan
track: ${track} # Preserved from initiative-state.yaml or defaults to full
priority: medium
created: <timestamp>
updated: <timestamp>
team:
- username: {username}
role: lead
phase_transitions: ${phase_transitions} # Preserved from initiative-state.yaml
artifacts: ${artifacts} # Preserved from initiative-state.yaml
migrated_from: platform-identity-auth-login
topology: "2-branch"
branches:
root: auth-login
plan: auth-login-planfeature-index.yaml Entry
Added entry format:
features:
- featureId: auth-login
domain: platform
service: identity
migrated_from: platform-identity-auth-login
added: <timestamp>Summary Stub (summaries/{featureId}.md)
Written to main branch at {governance_repo}/summaries/{featureId}.md:
# Auth Login
**Feature ID:** auth-login
**Domain:** platform
**Service:** identity
**Migrated from:** platform-identity-auth-login
**Migration date:** <timestamp>
## Summary
_To be filled in._Completion Summary
After all features are processed, show:
Migration complete:
✓ N features migrated successfully
✗ N features failed (see errors above)
⚠ N features skipped (conflicts)
Old branches preserved. To remove them, run cleanup explicitly.Cleanup Step
Cleanup is a separate, explicit operation and must never happen automatically.
Only run cleanup after: 1. Migration has completed successfully 2. New branches and feature.yaml files have been verified 3. User explicitly confirms: "Delete old branches? (yes/no)"
Cleanup deletes the directories under {governance_repo}/branches/ for migrated features only. Failed or skipped features are not cleaned up.
Scan Legacy Branches
Detect old-model branches in the governance repository and build a migration plan.
Outcome
A structured migration plan listing all detected legacy features with their derived domain, service, and feature ID, proposed new branch names, discovered milestones, and inferred current state. Conflicts with existing new-model features are surfaced.
Process
Run the scan operation:
python3 ./scripts/migrate-ops.py scan \
--governance-repo {governance_repo}With an optional custom branch pattern:
python3 ./scripts/migrate-ops.py scan \
--governance-repo {governance_repo} \
--branch-pattern "^your-pattern$"The script scans {governance_repo}/branches/ for directories matching the legacy pattern ^([a-z0-9-]+)-([a-z0-9-]+)-([a-z0-9-]+)(?:-([a-z0-9-]+))?$. It groups milestone branches under their base branch, derives domain/service/featureId, and detects conflicts.
Output Shape
{
"status": "pass",
"legacy_features": [
{
"old_id": "platform-identity-auth-login",
"derived_domain": "platform",
"derived_service": "identity",
"feature_id": "auth-login",
"milestones": ["planning", "dev"],
"proposed": {
"base_branch": "auth-login",
"plan_branch": "auth-login-plan"
},
"state": "dev"
}
],
"total": 1,
"conflicts": []
}Branch Grouping Logic
The scanner uses prefix-matching to identify milestone branches:
- If directory
A-B-C-Dexists alongsideA-B-C-D-planning, thenplanningis a milestone of featureA-B-C-D - The base branch (
A-B-C-D) is used to derive: domain=A, service=B, featureId=C-D - Standalone entries (not a suffix of any other) are always treated as base branches
Conflict Detection
A conflict is detected when {governance_repo}/features/{domain}/{service}/{featureId}/feature.yaml already exists for the derived featureId. Conflicts are listed separately and do not block other features from appearing in the migration plan.
After Scan
Present the migration plan as a table to the user:
| Old Branch | Feature ID | Domain | Service | Milestones | State | Conflict |
|---|---|---|---|---|---|---|
| platform-identity-auth-login | auth-login | platform | identity | planning, dev | dev | No |
Then offer to proceed to dry-run: "Ready to preview the migration? (yes/no)"
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Migration operations — scan legacy LENS v3 branches, check conflicts, migrate features.
Transitions features from the old domain-service-feature-milestone branch topology
to the Lens Next 2-branch model ({featureId} + {featureId}-plan).
"""
import argparse
import json
import os
import re
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import yaml
# Sanitization pattern for path-constructing identifiers
SAFE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
# Default legacy branch pattern
DEFAULT_BRANCH_PATTERN = r"^([a-z0-9-]+)-([a-z0-9-]+)-([a-z0-9-]+)(?:-([a-z0-9-]+))?$"
# Phase ordering for state derivation (earliest to latest)
PHASE_ORDER = ["planning", "businessplan", "techplan", "sprintplan", "dev", "complete"]
def validate_identifier(value: str, field_name: str) -> str | None:
"""Validate that a path-constructing identifier is safe. Returns error message or None."""
if not SAFE_ID_PATTERN.match(value):
return (
f"Invalid {field_name}: '{value}'. "
f"Must match [a-z0-9][a-z0-9._-]{{0,63}} (lowercase alphanumeric, dots, hyphens, underscores)."
)
return None
def now_iso() -> str:
"""Return current UTC time as ISO 8601 string."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def atomic_write_yaml(path: Path, data: dict) -> None:
"""Write YAML atomically via temp file + rename to prevent corruption."""
dir_path = path.parent
fd, tmp_path = tempfile.mkstemp(dir=str(dir_path), suffix=".yaml.tmp")
try:
with os.fdopen(fd, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
os.replace(tmp_path, str(path))
except Exception:
os.unlink(tmp_path)
raise
def group_legacy_branches(names: list[str]) -> dict[str, dict]:
"""Group branch names into base branches with their milestones.
Uses prefix-matching: if name B starts with name A + "-", then B is a
milestone branch of A, and B's suffix (after A + "-") is the milestone label.
Names not identified as milestones of any other name are treated as base branches.
"""
sorted_names = sorted(names)
milestone_map: dict[str, list[str]] = {}
is_milestone: set[str] = set()
for name in sorted_names:
for base in sorted_names:
if base == name:
continue
prefix = base + "-"
if name.startswith(prefix):
milestone = name[len(prefix):]
if base not in milestone_map:
milestone_map[base] = []
milestone_map[base].append(milestone)
is_milestone.add(name)
features = {}
for name in sorted_names:
if name in is_milestone:
continue
parts = name.split("-")
if len(parts) < 3:
continue
domain = parts[0]
service = parts[1]
feature_id = "-".join(parts[2:])
features[name] = {
"old_id": name,
"derived_domain": domain,
"derived_service": service,
"feature_id": feature_id,
"milestones": milestone_map.get(name, []),
}
return features
def derive_state(milestones: list[str]) -> str:
"""Derive current state from list of discovered milestone labels."""
for phase in reversed(PHASE_ORDER):
if phase in milestones:
return phase
return "preplan" if not milestones else "planning"
def cmd_scan(args: argparse.Namespace) -> dict:
"""Detect legacy branches and build migration plan."""
governance_repo = Path(args.governance_repo)
if not governance_repo.exists():
print(f"Error: Governance repo not found: {governance_repo}", file=sys.stderr)
sys.exit(1)
branches_dir = governance_repo / "branches"
if not branches_dir.exists():
return {"status": "pass", "legacy_features": [], "total": 0, "conflicts": []}
pattern_str = args.branch_pattern or DEFAULT_BRANCH_PATTERN
try:
pattern = re.compile(pattern_str)
except re.error as e:
return {"status": "fail", "error": f"Invalid branch pattern: {e}"}
candidate_names = []
for entry in branches_dir.iterdir():
if entry.is_dir() and pattern.match(entry.name):
candidate_names.append(entry.name)
if not candidate_names:
return {"status": "pass", "legacy_features": [], "total": 0, "conflicts": []}
grouped = group_legacy_branches(candidate_names)
legacy_features = []
conflicts = []
for base_name, info in sorted(grouped.items()):
feature_id = info["feature_id"]
domain = info["derived_domain"]
service = info["derived_service"]
milestones = info["milestones"]
state = derive_state(milestones)
new_feature_path = governance_repo / "features" / domain / service / feature_id / "feature.yaml"
if new_feature_path.exists():
conflicts.append({
"old_id": base_name,
"feature_id": feature_id,
"conflict_path": str(new_feature_path),
})
legacy_features.append({
"old_id": base_name,
"derived_domain": domain,
"derived_service": service,
"feature_id": feature_id,
"milestones": milestones,
"proposed": {
"base_branch": feature_id,
"plan_branch": f"{feature_id}-plan",
},
"state": state,
})
return {
"status": "pass",
"legacy_features": legacy_features,
"total": len(legacy_features),
"conflicts": conflicts,
}
def cmd_migrate_feature(args: argparse.Namespace) -> dict:
"""Execute migration for a single feature."""
governance_repo = Path(args.governance_repo)
if not governance_repo.exists():
print(f"Error: Governance repo not found: {governance_repo}", file=sys.stderr)
sys.exit(1)
for field_name, value in [
("feature-id", args.feature_id),
("domain", args.domain),
("service", args.service),
]:
err = validate_identifier(value, field_name)
if err:
return {"status": "fail", "error": err}
dry_run = args.dry_run
feature_id = args.feature_id
domain = args.domain
service = args.service
old_id = args.old_id
username = args.username or "unknown"
timestamp = now_iso()
feature_dir = governance_repo / "features" / domain / service / feature_id
feature_path = feature_dir / "feature.yaml"
index_path = governance_repo / "feature-index.yaml"
summary_path = governance_repo / "summaries" / f"{feature_id}.md"
if dry_run:
return {
"status": "pass",
"feature_id": feature_id,
"dry_run": True,
"planned_actions": [
f"Create feature.yaml at {feature_path}",
f"Update feature-index.yaml at {index_path}",
f"Create summary stub at {summary_path}",
],
"feature_yaml_created": False,
"index_updated": False,
}
# Create feature.yaml
feature_yaml_created = False
if not feature_path.exists():
feature_data = {
"featureId": feature_id,
"name": feature_id.replace("-", " ").title(),
"description": f"Migrated from legacy branch: {old_id}",
"domain": domain,
"service": service,
"phase": "preplan",
"track": "full",
"priority": "medium",
"created": timestamp,
"updated": timestamp,
"team": [{"username": username, "role": "lead"}],
"phase_transitions": [{"phase": "preplan", "timestamp": timestamp, "user": username}],
"migrated_from": old_id,
}
try:
feature_dir.mkdir(parents=True, exist_ok=True)
atomic_write_yaml(feature_path, feature_data)
feature_yaml_created = True
except OSError as e:
return {"status": "fail", "error": f"Failed to create feature.yaml: {e}"}
# Update feature-index.yaml
index_updated = False
try:
if index_path.exists():
with open(index_path) as f:
index_data = yaml.safe_load(f) or {}
else:
index_data = {"features": []}
if "features" not in index_data:
index_data["features"] = []
existing_ids = [e.get("featureId") for e in index_data["features"]]
if feature_id not in existing_ids:
index_data["features"].append({
"featureId": feature_id,
"domain": domain,
"service": service,
"migrated_from": old_id,
"added": timestamp,
})
index_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_yaml(index_path, index_data)
index_updated = True
except (OSError, yaml.YAMLError) as e:
return {"status": "fail", "error": f"Failed to update feature-index.yaml: {e}"}
# Create summary stub
summary_created = False
try:
if not summary_path.exists():
summary_path.parent.mkdir(parents=True, exist_ok=True)
with open(summary_path, "w") as f:
f.write(f"# {feature_id.replace('-', ' ').title()}\n\n")
f.write(f"**Feature ID:** {feature_id} \n")
f.write(f"**Domain:** {domain} \n")
f.write(f"**Service:** {service} \n")
f.write(f"**Migrated from:** {old_id} \n")
f.write(f"**Migration date:** {timestamp} \n\n")
f.write("## Summary\n\n_To be filled in._\n")
summary_created = True
except OSError as e:
return {"status": "fail", "error": f"Failed to create summary.md: {e}"}
return {
"status": "pass",
"feature_id": feature_id,
"dry_run": False,
"feature_yaml_created": feature_yaml_created,
"index_updated": index_updated,
"summary_created": summary_created,
}
def cmd_check_conflicts(args: argparse.Namespace) -> dict:
"""Check for naming conflicts before migration."""
governance_repo = Path(args.governance_repo)
if not governance_repo.exists():
print(f"Error: Governance repo not found: {governance_repo}", file=sys.stderr)
sys.exit(1)
feature_id = args.feature_id
domain = args.domain
service = args.service
target_path = governance_repo / "features" / domain / service / feature_id / "feature.yaml"
if target_path.exists():
return {
"status": "conflict",
"conflict": True,
"existing_path": str(target_path),
}
return {
"status": "pass",
"conflict": False,
"existing_path": None,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Migration operations — scan legacy LENS v3 branches and migrate to Lens Next model.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s scan --governance-repo /path/to/repo
%(prog)s scan --governance-repo /path/to/repo --branch-pattern "^custom-.*$"
%(prog)s check-conflicts --governance-repo /path/to/repo \\
--feature-id auth-login --domain platform --service identity
%(prog)s migrate-feature --governance-repo /path/to/repo \\
--old-id platform-identity-auth-login --feature-id auth-login \\
--domain platform --service identity --username cweber --dry-run
%(prog)s migrate-feature --governance-repo /path/to/repo \\
--old-id platform-identity-auth-login --feature-id auth-login \\
--domain platform --service identity --username cweber
""",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# scan
scan_p = subparsers.add_parser("scan", help="Detect legacy branches and build migration plan")
scan_p.add_argument("--governance-repo", required=True, help="Path to governance repo root")
scan_p.add_argument("--branch-pattern", help="Optional regex override for branch pattern detection")
# migrate-feature
mig_p = subparsers.add_parser("migrate-feature", help="Execute migration for a single feature")
mig_p.add_argument("--governance-repo", required=True, help="Path to governance repo root")
mig_p.add_argument("--old-id", required=True, help="Old branch name (legacy ID)")
mig_p.add_argument("--feature-id", required=True, help="New feature ID (kebab-case)")
mig_p.add_argument("--domain", required=True, help="Domain name")
mig_p.add_argument("--service", required=True, help="Service name")
mig_p.add_argument("--username", default="unknown", help="Username performing the migration")
mig_p.add_argument("--dry-run", action="store_true", help="Preview without making changes")
# check-conflicts
cc_p = subparsers.add_parser("check-conflicts", help="Check for naming conflicts before migration")
cc_p.add_argument("--governance-repo", required=True, help="Path to governance repo root")
cc_p.add_argument("--feature-id", required=True, help="Target feature ID")
cc_p.add_argument("--domain", required=True, help="Domain name")
cc_p.add_argument("--service", required=True, help="Service name")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
commands = {
"scan": cmd_scan,
"migrate-feature": cmd_migrate_feature,
"check-conflicts": cmd_check_conflicts,
}
result = commands[args.command](args)
json.dump(result, sys.stdout, indent=2, default=str)
print()
status = result.get("status", "fail")
sys.exit(0 if status in ("pass", "conflict") else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Tests for migrate-ops.py."""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import yaml
SCRIPT = str(Path(__file__).parent.parent / "migrate-ops.py")
PASS = 0
FAIL = 0
def run(args: list[str]) -> tuple[dict, int]:
"""Run the script and return parsed JSON output."""
result = subprocess.run(
[sys.executable, SCRIPT] + args,
capture_output=True,
text=True,
)
try:
return json.loads(result.stdout), result.returncode
except json.JSONDecodeError:
return {"error": result.stderr, "stdout": result.stdout}, result.returncode
def assert_eq(name: str, actual, expected):
global PASS, FAIL
if actual == expected:
PASS += 1
print(f" ✓ {name}", file=sys.stderr)
else:
FAIL += 1
print(f" ✗ {name}: expected {expected!r}, got {actual!r}", file=sys.stderr)
def assert_true(name: str, actual):
assert_eq(name, bool(actual), True)
def make_branch_dir(tmp: str, branch_name: str) -> Path:
"""Create a legacy branch directory in the branches/ folder."""
d = Path(tmp) / "branches" / branch_name
d.mkdir(parents=True, exist_ok=True)
return d
def test_scan_detects_legacy():
"""scan detects legacy feature directories."""
print("test_scan_detects_legacy", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
make_branch_dir(tmp, "platform-identity-auth-login")
result, code = run(["scan", "--governance-repo", tmp])
assert_eq("scan status", result["status"], "pass")
assert_eq("scan exit code", code, 0)
assert_eq("scan total", result["total"], 1)
assert_true("has legacy_features", len(result["legacy_features"]) == 1)
def test_scan_derives_components():
"""scan derives domain/service/featureId from old naming."""
print("test_scan_derives_components", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
make_branch_dir(tmp, "platform-identity-auth-login")
make_branch_dir(tmp, "platform-identity-auth-login-planning")
make_branch_dir(tmp, "platform-identity-auth-login-dev")
result, code = run(["scan", "--governance-repo", tmp])
assert_eq("scan status", result["status"], "pass")
assert_eq("total features", result["total"], 1)
feature = result["legacy_features"][0]
assert_eq("derived_domain", feature["derived_domain"], "platform")
assert_eq("derived_service", feature["derived_service"], "identity")
assert_eq("feature_id", feature["feature_id"], "auth-login")
assert_eq("old_id", feature["old_id"], "platform-identity-auth-login")
assert_true("has planning milestone", "planning" in feature["milestones"])
assert_true("has dev milestone", "dev" in feature["milestones"])
assert_eq("proposed base_branch", feature["proposed"]["base_branch"], "auth-login")
assert_eq("proposed plan_branch", feature["proposed"]["plan_branch"], "auth-login-plan")
def test_scan_empty_branches():
"""scan returns empty plan when no branches dir exists."""
print("test_scan_empty_branches", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run(["scan", "--governance-repo", tmp])
assert_eq("empty scan status", result["status"], "pass")
assert_eq("empty scan total", result["total"], 0)
assert_eq("empty scan exit code", code, 0)
def test_scan_multiple_features():
"""scan groups multiple features correctly."""
print("test_scan_multiple_features", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
make_branch_dir(tmp, "platform-identity-auth-login")
make_branch_dir(tmp, "core-api-user-mgmt")
make_branch_dir(tmp, "core-api-user-mgmt-dev")
result, code = run(["scan", "--governance-repo", tmp])
assert_eq("scan status", result["status"], "pass")
assert_eq("total features", result["total"], 2)
ids = [f["feature_id"] for f in result["legacy_features"]]
assert_true("has auth-login", "auth-login" in ids)
assert_true("has user-mgmt", "user-mgmt" in ids)
user_mgmt = next(f for f in result["legacy_features"] if f["feature_id"] == "user-mgmt")
assert_true("user-mgmt has dev milestone", "dev" in user_mgmt["milestones"])
def test_migrate_feature_creates_yaml():
"""migrate-feature creates feature.yaml."""
print("test_migrate_feature_creates_yaml", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"migrate-feature",
"--governance-repo", tmp,
"--old-id", "platform-identity-auth-login",
"--feature-id", "auth-login",
"--domain", "platform",
"--service", "identity",
"--username", "testuser",
])
assert_eq("migrate status", result["status"], "pass")
assert_eq("migrate exit code", code, 0)
assert_eq("feature_yaml_created", result["feature_yaml_created"], True)
feature_path = Path(tmp) / "features" / "platform" / "identity" / "auth-login" / "feature.yaml"
assert_eq("feature.yaml exists", feature_path.exists(), True)
with open(feature_path) as f:
data = yaml.safe_load(f)
assert_eq("featureId field", data["featureId"], "auth-login")
assert_eq("domain field", data["domain"], "platform")
assert_eq("service field", data["service"], "identity")
assert_eq("migrated_from field", data["migrated_from"], "platform-identity-auth-login")
assert_eq("phase field", data["phase"], "preplan")
assert_true("has team lead", data["team"][0]["role"] == "lead")
def test_migrate_feature_creates_index_entry():
"""migrate-feature creates feature-index.yaml entry."""
print("test_migrate_feature_creates_index_entry", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"migrate-feature",
"--governance-repo", tmp,
"--old-id", "core-api-user-mgmt",
"--feature-id", "user-mgmt",
"--domain", "core",
"--service", "api",
"--username", "testuser",
])
assert_eq("migrate status", result["status"], "pass")
assert_eq("index_updated", result["index_updated"], True)
index_path = Path(tmp) / "feature-index.yaml"
assert_eq("feature-index.yaml exists", index_path.exists(), True)
with open(index_path) as f:
index = yaml.safe_load(f)
feature_ids = [e["featureId"] for e in index.get("features", [])]
assert_true("feature in index", "user-mgmt" in feature_ids)
entry = next(e for e in index["features"] if e["featureId"] == "user-mgmt")
assert_eq("index entry domain", entry["domain"], "core")
assert_eq("index entry migrated_from", entry["migrated_from"], "core-api-user-mgmt")
def test_migrate_feature_dry_run():
"""migrate-feature dry-run makes no changes."""
print("test_migrate_feature_dry_run", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"migrate-feature",
"--governance-repo", tmp,
"--old-id", "platform-identity-auth-login",
"--feature-id", "auth-login",
"--domain", "platform",
"--service", "identity",
"--username", "testuser",
"--dry-run",
])
assert_eq("dry_run status", result["status"], "pass")
assert_eq("dry_run flag", result["dry_run"], True)
assert_eq("dry_run no yaml created", result["feature_yaml_created"], False)
assert_eq("dry_run no index updated", result["index_updated"], False)
assert_true("planned_actions present", len(result.get("planned_actions", [])) > 0)
feature_path = Path(tmp) / "features" / "platform" / "identity" / "auth-login" / "feature.yaml"
assert_eq("feature.yaml not created in dry run", feature_path.exists(), False)
index_path = Path(tmp) / "feature-index.yaml"
assert_eq("index not created in dry run", index_path.exists(), False)
def test_check_conflicts_no_conflict():
"""check-conflicts returns pass when target path is free."""
print("test_check_conflicts_no_conflict", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"check-conflicts",
"--governance-repo", tmp,
"--feature-id", "auth-login",
"--domain", "platform",
"--service", "identity",
])
assert_eq("no conflict status", result["status"], "pass")
assert_eq("conflict false", result["conflict"], False)
assert_eq("exit code 0", code, 0)
def test_check_conflicts_conflict():
"""check-conflicts returns conflict when target path exists."""
print("test_check_conflicts_conflict", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
feature_dir = Path(tmp) / "features" / "platform" / "identity" / "auth-login"
feature_dir.mkdir(parents=True)
with open(feature_dir / "feature.yaml", "w") as f:
yaml.dump({"featureId": "auth-login"}, f)
result, code = run([
"check-conflicts",
"--governance-repo", tmp,
"--feature-id", "auth-login",
"--domain", "platform",
"--service", "identity",
])
assert_eq("conflict status", result["status"], "conflict")
assert_eq("conflict true", result["conflict"], True)
assert_true("existing_path set", bool(result.get("existing_path")))
assert_eq("conflict exit code", code, 0)
def test_invalid_feature_id():
"""Invalid feature-id slug is rejected with exit code 1."""
print("test_invalid_feature_id", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"migrate-feature",
"--governance-repo", tmp,
"--old-id", "platform-identity-auth-login",
"--feature-id", "Auth_Login!",
"--domain", "platform",
"--service", "identity",
"--username", "testuser",
])
assert_eq("invalid id status", result["status"], "fail")
assert_eq("invalid id exit code", code, 1)
assert_true("error mentions Invalid", "Invalid" in result.get("error", ""))
def test_invalid_domain():
"""Invalid domain (path traversal) is rejected."""
print("test_invalid_domain", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"migrate-feature",
"--governance-repo", tmp,
"--old-id", "platform-identity-auth-login",
"--feature-id", "auth-login",
"--domain", "../evil",
"--service", "identity",
"--username", "testuser",
])
assert_eq("invalid domain status", result["status"], "fail")
assert_eq("invalid domain exit code", code, 1)
def test_governance_repo_not_found():
"""Governance repo not found causes exit code 1."""
print("test_governance_repo_not_found", file=sys.stderr)
result, code = run([
"scan",
"--governance-repo", "/nonexistent/path/to/repo/xyz123",
])
assert_eq("missing repo exit code", code, 1)
def test_governance_repo_not_found_migrate():
"""Governance repo not found on migrate-feature causes exit code 1."""
print("test_governance_repo_not_found_migrate", file=sys.stderr)
result, code = run([
"migrate-feature",
"--governance-repo", "/nonexistent/path/xyz123",
"--old-id", "platform-identity-auth",
"--feature-id", "auth",
"--domain", "platform",
"--service", "identity",
"--username", "testuser",
])
assert_eq("missing repo exit code (migrate)", code, 1)
def test_scan_detects_conflict():
"""scan surfaces conflict when new-model feature.yaml already exists."""
print("test_scan_detects_conflict", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
make_branch_dir(tmp, "platform-identity-auth-login")
# Pre-create the target feature.yaml
feature_dir = Path(tmp) / "features" / "platform" / "identity" / "auth-login"
feature_dir.mkdir(parents=True)
with open(feature_dir / "feature.yaml", "w") as f:
yaml.dump({"featureId": "auth-login"}, f)
result, code = run(["scan", "--governance-repo", tmp])
assert_eq("scan with conflict status", result["status"], "pass")
assert_eq("conflicts detected", len(result["conflicts"]), 1)
assert_eq("conflict old_id", result["conflicts"][0]["old_id"], "platform-identity-auth-login")
if __name__ == "__main__":
test_scan_detects_legacy()
test_scan_derives_components()
test_scan_empty_branches()
test_scan_multiple_features()
test_migrate_feature_creates_yaml()
test_migrate_feature_creates_index_entry()
test_migrate_feature_dry_run()
test_check_conflicts_no_conflict()
test_check_conflicts_conflict()
test_invalid_feature_id()
test_invalid_domain()
test_governance_repo_not_found()
test_governance_repo_not_found_migrate()
test_scan_detects_conflict()
print(f"\n{'='*40}", file=sys.stderr)
print(f"Results: {PASS} passed, {FAIL} failed", file=sys.stderr)
print(f"{'='*40}", file=sys.stderr)
sys.exit(1 if FAIL > 0 else 0)