#!/usr/bin/env python3 """migrate_legacy_project_names – archiviert Projekte mit ungültigen Namen. Bug 1+2 Begleit-Skript (Plan v3 §3.6): - Iteriert über alle Projekte in der DB. - Prüft jeden Namen gegen PROJECT_NAME_PATTERN + FORBIDDEN_PROJECT_NAMES. - Markiert ungültige Namen mit status='archived' in project_state (idempotent). - Bestehende 'active'-Projekte mit ungültigem Namen werden zu 'archived'. - Bereits archivierte werden NICHT doppelt verändert (idempotent). Aufruf: python3 utils/migrate_legacy_project_names.py [--dry-run] [--verbose] Defaults: - --dry-run: nur Report, keine DB-Schreibzugriffe. - --verbose: zusätzlich pro Projekt-Status ausgeben. Exit-Code: - 0: Erfolg (inkl. 0 archivierte Projekte). - 1: DB-Fehler. """ from __future__ import annotations import argparse import os import sys from typing import List, Tuple PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Run from /a0 to have usr.plugins.* resolvable. from usr.plugins.a0_software_orchestrator.helpers.db_state_store import ( _db, _db_error_handler, _validate_project_name, PROJECT_NAME_PATTERN, FORBIDDEN_PROJECT_NAMES, ) from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError @_db_error_handler def list_projects_with_states() -> List[Tuple[int, str, str]]: """Returns Liste von (project_id, project_name, current_status). Liest aus projects JOIN project_state. Projekte ohne project_state-Eintrag bekommen den Status ''. """ db = _db() rows = db.conn.execute(""" SELECT p.id, p.name, COALESCE(ps.status, '') as status FROM projects p LEFT JOIN project_state ps ON ps.project_id = p.id ORDER BY p.id """).fetchall() return [(int(r[0]), str(r[1]), str(r[2])) for r in rows] @_db_error_handler def archive_project(project_id: int, reason: str) -> None: """Markiert ein Projekt als 'archived' in project_state. Idempotent. Zwei Schritte statt ON CONFLICT DO UPDATE SET mit updated_at: das vermeidet sporadische 'no such column: updated_at'-Fehler bei cross-Singleton-Connections (zwei verschiedene Modul-Importe der PatternDB führen zu zwei Connections auf die gleiche DB-Datei). """ db = _db() # 1. Status archivieren (INSERT oder UPDATE) db.conn.execute(""" INSERT INTO project_state (project_id, status, phase, last_active_at, updated_at) VALUES (?, 'archived', 'archived', datetime('now'), datetime('now')) ON CONFLICT(project_id) DO UPDATE SET status = 'archived', phase = 'archived', last_active_at = datetime('now') """, (project_id,)) # 2. updated_at explizit setzen (separates Statement, robuster) db.conn.execute(""" UPDATE project_state SET updated_at = datetime('now') WHERE project_id = ? """, (project_id,)) # NOTE: projects-Tabelle hat kein updated_at-Feld (Spalten: id, name, # git_url, description, tech_stack, created_at, project_path, …). # Daher kein UPDATE projects hier. Wenn projects.updated_at per # Migration ergänzt wird, kann dieser Block nachgerüstet werden. db.conn.commit() sys.stderr.write(f" [ARCHIVED] project_id={project_id} reason={reason}\n") def main() -> int: ap = argparse.ArgumentParser( description="Archiviere Projekte mit ungültigen Namen (Pattern/Blacklist)." ) ap.add_argument( "--dry-run", action="store_true", help="Nur anzeigen, welche Projekte archiviert würden; keine DB-Schreibzugriffe.", ) ap.add_argument( "--verbose", "-v", action="store_true", help="Zusätzlich pro Projekt den Validierungs-Status ausgeben.", ) args = ap.parse_args() try: projects = list_projects_with_states() except PluginDBError as e: print(f"ERROR: DB-Fehler beim Lesen der Projekte: {e}", file=sys.stderr) return 1 if not projects: print("No projects found in DB.") return 0 print(f"=== Legacy-Name Migration (dry-run={args.dry_run}) ===") print(f"Pattern: {PROJECT_NAME_PATTERN.pattern}") print(f"Blacklist: {sorted(FORBIDDEN_PROJECT_NAMES)}") print(f"Projects in DB: {len(projects)}") print() active_count = 0 archived_count = 0 already_archived_count = 0 invalid_count = 0 for pid, name, current_status in projects: try: _validate_project_name(name) valid = True invalid_reason = None except ValueError as e: valid = False invalid_reason = str(e) if valid: active_count += 1 if args.verbose: print(f" [OK ] pid={pid:3d} name={name!r:40s} status={current_status}") continue invalid_count += 1 if current_status == "archived": already_archived_count += 1 print(f" [SKIP] pid={pid:3d} name={name!r} already archived; invalid={invalid_reason}") continue print(f" [BAD ] pid={pid:3d} name={name!r} status={current_status} reason={invalid_reason}") if not args.dry_run: try: archive_project(pid, reason=invalid_reason) archived_count += 1 except PluginDBError as e: print(f"ERROR: konnte pid={pid} nicht archivieren: {e}", file=sys.stderr) return 1 print() print(f"Summary:") print(f" total projects: {len(projects)}") print(f" valid (active): {active_count}") print(f" invalid (total): {invalid_count}") print(f" newly archived: {archived_count}") print(f" already archived: {already_archived_count}") print(f" mode: {'DRY-RUN' if args.dry_run else 'APPLIED'}") return 0 if __name__ == "__main__": sys.exit(main())