forked from cardosofelipe/fast-next-template
Infrastructure: - Add Redis and Celery workers to all docker-compose files - Fix celery migration race condition in entrypoint.sh - Add healthchecks and resource limits to dev compose - Update .env.template with Redis/Celery variables Backend Models & Schemas: - Rename Sprint.completed_points to velocity (per requirements) - Add AgentInstance.name as required field - Rename Issue external tracker fields for consistency - Add IssueSource and TrackerType enums - Add Project.default_tracker_type field Backend Fixes: - Add Celery retry configuration with exponential backoff - Remove unused sequence counter from EventBus - Add mypy overrides for test dependencies - Fix test file using wrong schema (UserUpdate -> dict) Frontend Fixes: - Fix memory leak in useProjectEvents (proper cleanup) - Fix race condition with stale closure in reconnection - Sync TokenWithUser type with regenerated API client - Fix expires_in null handling in useAuth - Clean up unused imports in prototype pages - Add ESLint relaxed rules for prototype files CI/CD: - Add E2E testing stage with Testcontainers - Add security scanning with Trivy and pip-audit - Add dependency caching for faster builds Tests: - Update all tests to use renamed fields (velocity, name, etc.) - Fix 14 schema test failures - All 1500 tests pass with 91% coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
459 lines
14 KiB
Python
Executable File
459 lines
14 KiB
Python
Executable File
#!/usr/bin/env python
|
|
"""
|
|
Database migration helper script.
|
|
Provides convenient commands for generating and applying Alembic migrations.
|
|
|
|
Usage:
|
|
# Generate migration (auto-increments revision ID: 0001, 0002, etc.)
|
|
python migrate.py --local generate "Add new field"
|
|
python migrate.py --local auto "Add new field"
|
|
|
|
# Apply migrations
|
|
python migrate.py --local apply
|
|
|
|
# Show next revision ID
|
|
python migrate.py next
|
|
|
|
# Reset after deleting migrations (clears alembic_version table)
|
|
python migrate.py --local reset
|
|
|
|
# Override auto-increment with custom revision ID
|
|
python migrate.py --local generate "initial_models" --rev-id custom_id
|
|
|
|
# Generate empty migration template without database (no autogenerate)
|
|
python migrate.py generate "Add performance indexes" --offline
|
|
|
|
# Inside Docker (without --local flag):
|
|
python migrate.py auto "Add new field"
|
|
"""
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Ensure the project root is in the Python path
|
|
project_root = Path(__file__).resolve().parent
|
|
if str(project_root) not in sys.path:
|
|
sys.path.append(str(project_root))
|
|
|
|
|
|
def setup_database_url(use_local: bool) -> str:
|
|
"""Setup database URL, optionally using localhost for local development."""
|
|
if use_local:
|
|
# Override DATABASE_URL to use localhost instead of Docker hostname
|
|
local_url = os.environ.get(
|
|
"LOCAL_DATABASE_URL",
|
|
"postgresql://postgres:postgres@localhost:5432/app"
|
|
)
|
|
os.environ["DATABASE_URL"] = local_url
|
|
return local_url
|
|
|
|
# Use the configured DATABASE_URL from environment/.env
|
|
from app.core.config import settings
|
|
return settings.database_url
|
|
|
|
|
|
def check_models():
|
|
"""Check if all models are properly imported"""
|
|
print("Checking model imports...")
|
|
|
|
try:
|
|
# Import all models through the models package
|
|
from app.models import __all__ as all_models
|
|
print(f"Found {len(all_models)} model(s):")
|
|
for model in all_models:
|
|
print(f" - {model}")
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error checking models: {e}")
|
|
return False
|
|
|
|
|
|
def generate_migration(message, rev_id=None, auto_rev_id=True, offline=False):
|
|
"""Generate an Alembic migration with the given message.
|
|
|
|
Args:
|
|
message: Migration message
|
|
rev_id: Custom revision ID (overrides auto_rev_id)
|
|
auto_rev_id: If True and rev_id is None, auto-generate sequential ID
|
|
offline: If True, generate empty migration without database (no autogenerate)
|
|
"""
|
|
# Auto-generate sequential revision ID if not provided
|
|
if rev_id is None and auto_rev_id:
|
|
rev_id = get_next_rev_id()
|
|
|
|
print(f"Generating migration: {message}")
|
|
if rev_id:
|
|
print(f"Using revision ID: {rev_id}")
|
|
|
|
if offline:
|
|
# Generate migration file directly without database connection
|
|
return generate_offline_migration(message, rev_id)
|
|
|
|
cmd = ["alembic", "revision", "--autogenerate", "-m", message]
|
|
if rev_id:
|
|
cmd.extend(["--rev-id", rev_id])
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
print(result.stdout)
|
|
if result.returncode != 0:
|
|
print("Error generating migration:", file=sys.stderr)
|
|
print(result.stderr, file=sys.stderr)
|
|
return False
|
|
|
|
# Extract revision ID if possible
|
|
revision = None
|
|
for line in result.stdout.split("\n"):
|
|
if "Generating" in line and "..." in line:
|
|
try:
|
|
# Look for the revision ID, which is typically 12 hex characters
|
|
parts = line.split()
|
|
for part in parts:
|
|
if len(part) >= 12 and all(c in "0123456789abcdef" for c in part[:12]):
|
|
revision = part[:12]
|
|
break
|
|
except Exception as e:
|
|
# If parsing fails, we can still proceed without a detected revision
|
|
print(f"Warning: could not parse revision from line '{line}': {e}")
|
|
|
|
if revision:
|
|
print(f"Generated revision: {revision}")
|
|
else:
|
|
print("Generated migration (revision ID not identified)")
|
|
|
|
return revision or True
|
|
|
|
|
|
def apply_migration(revision=None):
|
|
"""Apply migrations up to the specified revision or head"""
|
|
target = revision or "head"
|
|
print(f"Applying migration(s) to: {target}")
|
|
|
|
cmd = ["alembic", "upgrade", target]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
print(result.stdout)
|
|
if result.returncode != 0:
|
|
print("Error applying migration:", file=sys.stderr)
|
|
print(result.stderr, file=sys.stderr)
|
|
return False
|
|
|
|
print("Migration(s) applied successfully")
|
|
return True
|
|
|
|
|
|
def show_current():
|
|
"""Show current revision"""
|
|
print("Current database revision:")
|
|
|
|
cmd = ["alembic", "current"]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
print(result.stdout)
|
|
if result.returncode != 0:
|
|
print("Error getting current revision:", file=sys.stderr)
|
|
print(result.stderr, file=sys.stderr)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def list_migrations():
|
|
"""List all migrations and their status"""
|
|
print("Listing migrations:")
|
|
|
|
cmd = ["alembic", "history", "--verbose"]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
print(result.stdout)
|
|
if result.returncode != 0:
|
|
print("Error listing migrations:", file=sys.stderr)
|
|
print(result.stderr, file=sys.stderr)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def check_database_connection():
|
|
"""Check if database is accessible"""
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
try:
|
|
# Use DATABASE_URL from environment (set by setup_database_url)
|
|
db_url = os.environ.get("DATABASE_URL")
|
|
if not db_url:
|
|
from app.core.config import settings
|
|
db_url = settings.database_url
|
|
|
|
engine = create_engine(db_url)
|
|
with engine.connect():
|
|
print("✓ Database connection successful!")
|
|
return True
|
|
except SQLAlchemyError as e:
|
|
print(f"✗ Error connecting to database: {e}")
|
|
return False
|
|
|
|
|
|
def get_next_rev_id():
|
|
"""Get the next sequential revision ID based on existing migrations."""
|
|
import re
|
|
|
|
versions_dir = project_root / "app" / "alembic" / "versions"
|
|
if not versions_dir.exists():
|
|
return "0001"
|
|
|
|
# Find all migration files with numeric prefixes
|
|
max_num = 0
|
|
pattern = re.compile(r"^(\d{4})_.*\.py$")
|
|
|
|
for f in versions_dir.iterdir():
|
|
if f.is_file() and f.suffix == ".py":
|
|
match = pattern.match(f.name)
|
|
if match:
|
|
num = int(match.group(1))
|
|
max_num = max(max_num, num)
|
|
|
|
next_num = max_num + 1
|
|
return f"{next_num:04d}"
|
|
|
|
|
|
def get_current_rev_id():
|
|
"""Get the current (latest) revision ID from existing migrations."""
|
|
import re
|
|
|
|
versions_dir = project_root / "app" / "alembic" / "versions"
|
|
if not versions_dir.exists():
|
|
return None
|
|
|
|
# Find all migration files with numeric prefixes and get the highest
|
|
max_num = 0
|
|
max_rev_id = None
|
|
pattern = re.compile(r"^(\d{4})_.*\.py$")
|
|
|
|
for f in versions_dir.iterdir():
|
|
if f.is_file() and f.suffix == ".py":
|
|
match = pattern.match(f.name)
|
|
if match:
|
|
num = int(match.group(1))
|
|
if num > max_num:
|
|
max_num = num
|
|
max_rev_id = match.group(1)
|
|
|
|
return max_rev_id
|
|
|
|
|
|
def generate_offline_migration(message, rev_id):
|
|
"""Generate a migration file without database connection.
|
|
|
|
Creates an empty migration template that can be filled in manually.
|
|
Useful for performance indexes or when database is not available.
|
|
"""
|
|
from datetime import datetime
|
|
|
|
versions_dir = project_root / "app" / "alembic" / "versions"
|
|
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Slugify the message for filename
|
|
slug = message.lower().replace(" ", "_").replace("-", "_")
|
|
slug = "".join(c for c in slug if c.isalnum() or c == "_")
|
|
|
|
filename = f"{rev_id}_{slug}.py"
|
|
filepath = versions_dir / filename
|
|
|
|
# Get the previous revision ID
|
|
down_revision = get_current_rev_id()
|
|
down_rev_str = f'"{down_revision}"' if down_revision else "None"
|
|
|
|
# Generate the migration file content
|
|
content = f'''"""{message}
|
|
|
|
Revision ID: {rev_id}
|
|
Revises: {down_revision or ''}
|
|
Create Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}
|
|
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = "{rev_id}"
|
|
down_revision: str | None = {down_rev_str}
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# TODO: Add your upgrade operations here
|
|
pass
|
|
|
|
|
|
def downgrade() -> None:
|
|
# TODO: Add your downgrade operations here
|
|
pass
|
|
'''
|
|
|
|
filepath.write_text(content)
|
|
print(f"Generated offline migration: {filepath}")
|
|
return rev_id
|
|
|
|
|
|
def show_next_rev_id():
|
|
"""Show the next sequential revision ID."""
|
|
next_id = get_next_rev_id()
|
|
print(f"Next revision ID: {next_id}")
|
|
print("\nUsage:")
|
|
print(f" python migrate.py --local generate 'your_message' --rev-id {next_id}")
|
|
print(f" python migrate.py --local auto 'your_message' --rev-id {next_id}")
|
|
return next_id
|
|
|
|
|
|
def reset_alembic_version():
|
|
"""Reset the alembic_version table (for fresh start after deleting migrations)."""
|
|
from sqlalchemy import create_engine, text
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
db_url = os.environ.get("DATABASE_URL")
|
|
if not db_url:
|
|
from app.core.config import settings
|
|
db_url = settings.database_url
|
|
|
|
try:
|
|
engine = create_engine(db_url)
|
|
with engine.connect() as conn:
|
|
conn.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
|
conn.commit()
|
|
print("✓ Alembic version table reset successfully")
|
|
print(" You can now run migrations from scratch")
|
|
return True
|
|
except SQLAlchemyError as e:
|
|
print(f"✗ Error resetting alembic version: {e}")
|
|
return False
|
|
|
|
|
|
def main():
|
|
"""Main function"""
|
|
parser = argparse.ArgumentParser(
|
|
description='Database migration helper for Generative Models Arena'
|
|
)
|
|
|
|
# Global options
|
|
parser.add_argument(
|
|
'--local', '-l',
|
|
action='store_true',
|
|
help='Use localhost instead of Docker hostname (for local development)'
|
|
)
|
|
|
|
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
|
|
|
# Generate command
|
|
generate_parser = subparsers.add_parser('generate', help='Generate a migration')
|
|
generate_parser.add_argument('message', help='Migration message')
|
|
generate_parser.add_argument(
|
|
'--rev-id',
|
|
help='Custom revision ID (e.g., 0001, 0002 for sequential naming)'
|
|
)
|
|
generate_parser.add_argument(
|
|
'--offline',
|
|
action='store_true',
|
|
help='Generate empty migration template without database connection'
|
|
)
|
|
|
|
# Apply command
|
|
apply_parser = subparsers.add_parser('apply', help='Apply migrations')
|
|
apply_parser.add_argument('--revision', help='Specific revision to apply to')
|
|
|
|
# List command
|
|
subparsers.add_parser('list', help='List migrations')
|
|
|
|
# Current command
|
|
subparsers.add_parser('current', help='Show current revision')
|
|
|
|
# Check command
|
|
subparsers.add_parser('check', help='Check database connection and models')
|
|
|
|
# Next command (show next revision ID)
|
|
subparsers.add_parser('next', help='Show the next sequential revision ID')
|
|
|
|
# Reset command (clear alembic_version table)
|
|
subparsers.add_parser(
|
|
'reset',
|
|
help='Reset alembic_version table (use after deleting all migrations)'
|
|
)
|
|
|
|
# Auto command (generate and apply)
|
|
auto_parser = subparsers.add_parser('auto', help='Generate and apply migration')
|
|
auto_parser.add_argument('message', help='Migration message')
|
|
auto_parser.add_argument(
|
|
'--rev-id',
|
|
help='Custom revision ID (e.g., 0001, 0002 for sequential naming)'
|
|
)
|
|
auto_parser.add_argument(
|
|
'--offline',
|
|
action='store_true',
|
|
help='Generate empty migration template without database connection'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Commands that don't need database connection
|
|
if args.command == 'next':
|
|
show_next_rev_id()
|
|
return
|
|
|
|
# Check if offline mode is requested
|
|
offline = getattr(args, 'offline', False)
|
|
|
|
# Offline generate doesn't need database or model check
|
|
if args.command == 'generate' and offline:
|
|
generate_migration(args.message, rev_id=args.rev_id, offline=True)
|
|
return
|
|
|
|
if args.command == 'auto' and offline:
|
|
generate_migration(args.message, rev_id=args.rev_id, offline=True)
|
|
print("\nOffline migration generated. Apply it later with:")
|
|
print(" python migrate.py --local apply")
|
|
return
|
|
|
|
# Setup database URL (must be done before importing settings elsewhere)
|
|
db_url = setup_database_url(args.local)
|
|
print(f"Using database URL: {db_url}")
|
|
|
|
if args.command == 'generate':
|
|
check_models()
|
|
generate_migration(args.message, rev_id=args.rev_id)
|
|
|
|
elif args.command == 'apply':
|
|
apply_migration(args.revision)
|
|
|
|
elif args.command == 'list':
|
|
list_migrations()
|
|
|
|
elif args.command == 'current':
|
|
show_current()
|
|
|
|
elif args.command == 'check':
|
|
check_database_connection()
|
|
check_models()
|
|
|
|
elif args.command == 'reset':
|
|
reset_alembic_version()
|
|
|
|
elif args.command == 'auto':
|
|
check_models()
|
|
revision = generate_migration(args.message, rev_id=args.rev_id)
|
|
if revision:
|
|
input("\nPress Enter to apply migration or Ctrl+C to abort... ")
|
|
apply_migration()
|
|
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|