""" Test Registry - Backlog Endpoints Endpoints for failed test backlog management. """ from datetime import datetime from typing import Optional from fastapi import APIRouter, HTTPException, Query from ...database import get_db_session from ...repository import TestRepository from ..api_models import ( BacklogStatusUpdate, BacklogPriorityUpdate, FixAttempt, ManualBacklogEntry, ) from ..config import ( get_test_runs, get_persisted_results, is_postgres_available, migrate_json_to_postgres, ) router = APIRouter() @router.get("/failed") async def get_failed_tests(): """ Gibt alle fehlgeschlagenen Tests aus den persistierten Ergebnissen zurueck. Fuer Backlog-Verwaltung mit menschenverstaendlichen Fehlerbeschreibungen. """ persisted_results = get_persisted_results() failed_tests = [] # Sammle fehlgeschlagene Tests aus persistierten Ergebnissen for service, data in persisted_results.items(): run_time = data.get("last_run", "") run_id = f"persisted_{service}" # Hole fehlgeschlagene Test-IDs for failed in data.get("failed_test_ids", []): if isinstance(failed, dict): failed_tests.append({ "id": failed.get("id", ""), "name": failed.get("name", ""), "service": service, "file_path": failed.get("file_path", ""), "line_number": failed.get("line_number"), "error_message": failed.get("error_message", "Keine Fehlermeldung verfuegbar"), "error_type": failed.get("error_type", "unknown"), "suggestion": failed.get("suggestion", ""), "run_id": run_id, "last_failed": run_time, "status": "open", # open, in_progress, fixed }) elif isinstance(failed, str): # Legacy-Format: nur Test-ID als String failed_tests.append({ "id": failed, "name": failed, "service": service, "file_path": "", "line_number": None, "error_message": "Keine Details verfuegbar", "error_type": "unknown", "suggestion": "", "run_id": run_id, "last_failed": run_time, "status": "open", }) # Dedupliziere nach Test-ID (nur neueste Version behalten) seen = {} for test in failed_tests: test_id = test["id"] if test_id not in seen or test["last_failed"] > seen[test_id]["last_failed"]: seen[test_id] = test unique_failed = list(seen.values()) # Gruppiere nach Service by_service = {} for test in unique_failed: service = test["service"] if service not in by_service: by_service[service] = [] by_service[service].append(test) return { "total_failed": len(unique_failed), "by_service": by_service, "tests": unique_failed, "last_updated": datetime.now().isoformat(), } @router.post("/failed/{test_id}/status") async def update_failed_test_status(test_id: str, status: str): """ Aktualisiert den Status eines fehlgeschlagenen Tests. Status: 'open', 'in_progress', 'fixed', 'wont_fix' Legacy-Endpoint - nutzt nun PostgreSQL wenn verfuegbar. """ valid_statuses = ["open", "in_progress", "fixed", "wont_fix", "flaky"] if status not in valid_statuses: raise HTTPException( status_code=400, detail=f"Ungueltiger Status. Erlaubt: {', '.join(valid_statuses)}" ) # Versuche in PostgreSQL zu speichern if is_postgres_available(): try: with get_db_session() as db: repo = TestRepository(db) # Suche nach Backlog-Item mit test_id backlog_items = repo.get_backlog() for item in backlog_items: if item.test_name == test_id or str(item.id) == test_id: repo.update_backlog_status(item.id, status) return { "test_id": test_id, "backlog_id": item.id, "status": status, "updated_at": datetime.now().isoformat(), "message": f"Test-Status auf '{status}' gesetzt (PostgreSQL)", } except Exception as e: print(f"PostgreSQL-Fehler: {e}") # Fallback: nur Bestaetigung zurueckgeben return { "test_id": test_id, "status": status, "updated_at": datetime.now().isoformat(), "message": f"Test-Status auf '{status}' gesetzt", } @router.get("/backlog") async def get_backlog( status: Optional[str] = Query(None, description="Filter nach Status: open, in_progress, fixed, wont_fix, flaky"), service: Optional[str] = Query(None, description="Filter nach Service"), priority: Optional[str] = Query(None, description="Filter nach Prioritaet: critical, high, medium, low"), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0) ): """ Gibt den persistenten Backlog fehlgeschlagener Tests zurueck. Der Backlog aggregiert fehlgeschlagene Tests ueber mehrere Runs hinweg und ermoeglicht Status-Management (open -> in_progress -> fixed). """ if not is_postgres_available(): # Fallback auf legacy /failed Endpoint return await get_failed_tests() try: with get_db_session() as db: repo = TestRepository(db) items = repo.get_backlog( status=status, service=service, priority=priority, limit=limit, offset=offset ) total = repo.get_backlog_count(status=status, service=service) # Gruppiere nach Service by_service = {} for item in items: svc = item.service if svc not in by_service: by_service[svc] = [] by_service[svc].append(item.to_dict()) return { "total": total, "items": [item.to_dict() for item in items], "by_service": by_service, "filters": { "status": status, "service": service, "priority": priority }, "pagination": { "limit": limit, "offset": offset } } except Exception as e: raise HTTPException(status_code=500, detail=f"Datenbankfehler: {str(e)}") @router.get("/backlog/{backlog_id}") async def get_backlog_item(backlog_id: int): """ Gibt Details zu einem einzelnen Backlog-Eintrag zurueck. Inklusive Fix-Historie. """ if not is_postgres_available(): raise HTTPException(status_code=503, detail="PostgreSQL nicht verfuegbar") try: with get_db_session() as db: repo = TestRepository(db) item = repo.get_backlog_item(backlog_id) if not item: raise HTTPException(status_code=404, detail=f"Backlog-Item {backlog_id} nicht gefunden") # Hole Fix-Historie fixes = repo.get_fix_history(backlog_id) result = item.to_dict() result["fixes"] = [fix.to_dict() for fix in fixes] return result except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Datenbankfehler: {str(e)}") @router.post("/backlog/{backlog_id}/status") async def update_backlog_item_status(backlog_id: int, update: BacklogStatusUpdate): """ Aktualisiert den Status eines Backlog-Eintrags. Moegliche Status: - open: Noch nicht bearbeitet - in_progress: Wird gerade bearbeitet - fixed: Test wurde gefixt - wont_fix: Wird nicht gefixt (mit Begruendung) - flaky: Flaky Test, wird separat behandelt """ valid_statuses = ["open", "in_progress", "fixed", "wont_fix", "flaky"] if update.status not in valid_statuses: raise HTTPException( status_code=400, detail=f"Ungueltiger Status. Erlaubt: {', '.join(valid_statuses)}" ) if not is_postgres_available(): raise HTTPException(status_code=503, detail="PostgreSQL nicht verfuegbar") try: with get_db_session() as db: repo = TestRepository(db) item = repo.update_backlog_status( backlog_id=backlog_id, status=update.status, notes=update.notes, assigned_to=update.assigned_to ) if not item: raise HTTPException(status_code=404, detail=f"Backlog-Item {backlog_id} nicht gefunden") return item.to_dict() except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Datenbankfehler: {str(e)}") @router.post("/backlog/{backlog_id}/priority") async def update_backlog_item_priority(backlog_id: int, update: BacklogPriorityUpdate): """ Aktualisiert die Prioritaet eines Backlog-Eintrags. Moegliche Prioritaeten: - critical: Kritisch - sofort beheben - high: Hoch - bald beheben - medium: Mittel - bei Gelegenheit - low: Niedrig - irgendwann """ valid_priorities = ["critical", "high", "medium", "low"] if update.priority not in valid_priorities: raise HTTPException( status_code=400, detail=f"Ungueltige Prioritaet. Erlaubt: {', '.join(valid_priorities)}" ) if not is_postgres_available(): raise HTTPException(status_code=503, detail="PostgreSQL nicht verfuegbar") try: with get_db_session() as db: repo = TestRepository(db) item = repo.update_backlog_priority(backlog_id, update.priority) if not item: raise HTTPException(status_code=404, detail=f"Backlog-Item {backlog_id} nicht gefunden") return item.to_dict() except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Datenbankfehler: {str(e)}") @router.post("/backlog/{backlog_id}/fix") async def add_fix_attempt(backlog_id: int, fix: FixAttempt): """ Fuegt einen Fix-Versuch zur Historie hinzu. Bei success=True wird der Backlog-Status automatisch auf 'fixed' gesetzt. """ if not is_postgres_available(): raise HTTPException(status_code=503, detail="PostgreSQL nicht verfuegbar") valid_fix_types = ["manual", "auto_claude", "auto_script"] if fix.fix_type not in valid_fix_types: raise HTTPException( status_code=400, detail=f"Ungueltiger Fix-Typ. Erlaubt: {', '.join(valid_fix_types)}" ) try: with get_db_session() as db: repo = TestRepository(db) # Pruefe ob Backlog-Item existiert item = repo.get_backlog_item(backlog_id) if not item: raise HTTPException(status_code=404, detail=f"Backlog-Item {backlog_id} nicht gefunden") # Fix-Versuch hinzufuegen fix_record = repo.add_fix_attempt( backlog_id=backlog_id, fix_type=fix.fix_type, fix_description=fix.fix_description, commit_hash=fix.commit_hash, success=fix.success ) return { "fix": fix_record.to_dict(), "backlog_status": "fixed" if fix.success else item.status } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Datenbankfehler: {str(e)}") @router.post("/backlog") async def create_backlog_entry(entry: ManualBacklogEntry): """ Erstellt einen manuellen Backlog-Eintrag. Nuetzlich fuer: - Nicht-integrierte Features (xfail Tests) - Bekannte Probleme die noch behoben werden muessen - Feature Requests aus dem Test-Kontext """ from ...db_models import FailedTestBacklogDB if not is_postgres_available(): raise HTTPException(status_code=503, detail="PostgreSQL nicht verfuegbar") valid_priorities = ["critical", "high", "medium", "low"] if entry.priority not in valid_priorities: raise HTTPException( status_code=400, detail=f"Ungueltige Prioritaet. Erlaubt: {', '.join(valid_priorities)}" ) try: with get_db_session() as db: now = datetime.utcnow() # Pruefe ob schon ein offener Eintrag existiert existing = db.query(FailedTestBacklogDB).filter( FailedTestBacklogDB.test_name == entry.test_name, FailedTestBacklogDB.service == entry.service, FailedTestBacklogDB.status == "open" ).first() if existing: # Aktualisiere existierenden Eintrag existing.error_message = entry.error_message existing.priority = entry.priority existing.fix_suggestion = entry.fix_suggestion existing.last_failed_at = now db.commit() return { "id": existing.id, "status": "updated", "message": f"Existierender Backlog-Eintrag aktualisiert" } # Neuen Eintrag erstellen backlog = FailedTestBacklogDB( test_name=entry.test_name, test_file=f"{entry.service}/", service=entry.service, framework="manual", error_message=entry.error_message, error_type="feature_not_integrated", status="open", priority=entry.priority, fix_suggestion=entry.fix_suggestion, first_failed_at=now, last_failed_at=now, failure_count=1 ) db.add(backlog) db.commit() db.refresh(backlog) return { "id": backlog.id, "status": "created", "message": f"Backlog-Eintrag erstellt: {entry.test_name}" } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Datenbankfehler: {str(e)}") @router.get("/history") async def get_test_history( service: Optional[str] = Query(None, description="Filter nach Service"), days: int = Query(30, ge=1, le=365, description="Anzahl Tage zurueck"), limit: int = Query(100, ge=1, le=1000) ): """ Gibt die Test-Run Historie fuer Trend-Analysen zurueck. Aggregiert Daten nach Tag und Service. """ test_runs = get_test_runs() if not is_postgres_available(): # Fallback auf In-Memory Historie sorted_runs = sorted(test_runs, key=lambda r: r["started_at"], reverse=True) return {"runs": sorted_runs[:limit], "source": "memory"} try: with get_db_session() as db: repo = TestRepository(db) history = repo.get_run_history( service=service, days=days, limit=limit ) return { "history": history, "days": days, "service": service, "source": "postgresql" } except Exception as e: # Fallback auf In-Memory sorted_runs = sorted(test_runs, key=lambda r: r["started_at"], reverse=True) return {"runs": sorted_runs[:limit], "source": "memory", "error": str(e)} @router.get("/trends") async def get_test_trends( service: Optional[str] = Query(None, description="Filter nach Service"), days: int = Query(14, ge=1, le=90, description="Anzahl Tage") ): """ Gibt Trend-Daten fuer Visualisierungen zurueck. Zeigt Pass-Rate und Test-Anzahl ueber Zeit. """ if not is_postgres_available(): raise HTTPException(status_code=503, detail="PostgreSQL nicht verfuegbar fuer Trends") try: with get_db_session() as db: repo = TestRepository(db) history = repo.get_run_history(service=service, days=days, limit=days * 20) # Aggregiere nach Tag by_date = {} for entry in history: date = entry["date"] if date not in by_date: by_date[date] = { "date": date, "total_tests": 0, "passed": 0, "failed": 0, "runs": 0 } by_date[date]["total_tests"] += entry["total_tests"] by_date[date]["passed"] += entry["passed"] by_date[date]["failed"] += entry["failed"] by_date[date]["runs"] += entry["runs"] # Berechne Pass-Rate pro Tag trends = [] for date, data in sorted(by_date.items()): total = data["total_tests"] data["pass_rate"] = round((data["passed"] / total * 100) if total > 0 else 0, 1) trends.append(data) return { "trends": trends, "days": days, "service": service } except Exception as e: raise HTTPException(status_code=500, detail=f"Datenbankfehler: {str(e)}") @router.get("/stats") async def get_aggregated_stats(): """ Gibt aggregierte Statistiken ueber alle Services zurueck. Kombiniert Daten aus PostgreSQL und Service-Definitionen. """ from ...models import TestRegistryStats persisted_results = get_persisted_results() if is_postgres_available(): try: with get_db_session() as db: repo = TestRepository(db) summary = repo.get_summary_stats() service_stats = repo.get_all_service_stats() return { "summary": summary, "services": [s.to_dict() for s in service_stats], "source": "postgresql" } except Exception as e: print(f"PostgreSQL-Fehler: {e}") # Fallback auf Legacy-Daten stats = TestRegistryStats() for service, data in persisted_results.items(): stats.total_tests += data.get("total", 0) stats.total_passed += data.get("passed", 0) stats.total_failed += data.get("failed", 0) stats.services_count = len(persisted_results) stats.overall_pass_rate = (stats.total_passed / stats.total_tests * 100) if stats.total_tests > 0 else 0 return { "summary": { "total_tests": stats.total_tests, "total_passed": stats.total_passed, "total_failed": stats.total_failed, "total_skipped": stats.total_skipped, "services_count": stats.services_count, "overall_pass_rate": round(stats.overall_pass_rate, 1) }, "services": list(persisted_results.keys()), "source": "memory" } @router.post("/migrate") async def trigger_migration(): """ Migriert bestehende JSON-Daten nach PostgreSQL. Einmalig ausfuehren um historische Daten zu uebernehmen. """ if not is_postgres_available(): raise HTTPException(status_code=503, detail="PostgreSQL nicht verfuegbar") count = migrate_json_to_postgres() return { "migrated_services": count, "message": f"{count} Services von JSON nach PostgreSQL migriert" }