diff --git a/.env.example b/.env.example index c60dc7e..2a85985 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,18 @@ REPOS_BASE_PATH=/tmp/autodev_repos # ── Logging ─────────────────────────────────────────────────────────────────── LOG_LEVEL=INFO LOG_FORMAT=json + +# ── API Security ────────────────────────────────────────────────────────────── +# When set, all /api/v1/* routes require: X-API-Key: OR Authorization: Bearer +# Leave empty for local development without auth. +API_KEY= + +# ── Frontend (local npm run dev only) ───────────────────────────────────────── +# Create frontend/.env.local with: +# BACKEND_API_URL=http://localhost:8000/api/v1 +# API_KEY= +# Docker Compose sets these automatically for the frontend container. + +# ── Rate limiting ───────────────────────────────────────────────────────────── +RATE_LIMIT_ENABLED=true +RATE_LIMIT_DEFAULT=120/minute diff --git a/Dockerfile b/Dockerfile index cf6b695..1395f74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,5 @@ FROM python:3.11-slim -# System dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ git \ curl \ @@ -9,20 +8,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -# Install Python deps first (layer cache) COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy app COPY backend/app ./app +COPY backend/alembic ./alembic +COPY backend/alembic.ini . +COPY backend/scripts/entrypoint.sh /entrypoint.sh -# Create repo storage dir -RUN mkdir -p /tmp/autodev_repos +RUN mkdir -p /tmp/autodev_repos \ + && chmod +x /entrypoint.sh -# Non-root user for security RUN useradd -m -u 1001 autodev && chown -R autodev:autodev /app /tmp/autodev_repos USER autodev EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index c289276..727b2d3 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,21 @@ Response: | `GET` | `/api/v1/repos/{id}/report` | Full analysis report + issues | | `GET` | `/api/v1/repos/{id}/refactors` | All refactor suggestions | | `POST` | `/api/v1/refactor` | Manually trigger refactor for issue | +| `GET` | `/api/v1/tasks/{task_id}` | Poll Celery task status | | `GET` | `/api/v1/stats` | Global aggregate stats | -| `GET` | `/health` | Health check | +| `GET` | `/health` | Health check (no auth) | + +### Authentication + +When `API_KEY` is set in `.env`, all `/api/v1/*` routes require: + +``` +X-API-Key: your-secret-key +# or +Authorization: Bearer your-secret-key +``` + +Leave `API_KEY` empty for local development without auth. --- @@ -175,6 +188,8 @@ autodev/ │ │ └── utils/ │ │ ├── ast_parser.py # Python AST extraction (stdlib ast) │ │ └── diff_validator.py # Diff safety checks +│ ├── alembic/ # Database migrations +│ │ └── versions/ │ ├── tests/ # pytest suite (DiffValidator, AST, API) │ ├── pytest.ini │ ├── ruff.toml @@ -215,6 +230,17 @@ cd frontend npm install && npm run dev ``` +### Database migrations (Alembic) + +```bash +cd backend +# Apply all migrations (runs automatically on Docker API startup) +alembic upgrade head + +# Create a new migration after model changes +alembic revision --autogenerate -m "describe change" +``` + ### Run tests ```bash @@ -237,6 +263,9 @@ ruff check app tests - [x] CI pipeline (GitHub Actions: ruff, pytest, frontend build) - [x] Backend test suite (DiffValidator, AST parser, API) +- [x] API key authentication + rate limiting +- [x] Alembic migrations + Docker auto-migrate +- [x] Celery task status endpoint (`GET /tasks/{id}`) - [ ] Multi-language support (JavaScript/TypeScript) - [ ] Embedding-based duplicate function detection - [ ] CLI: `autodev analyze ./project` diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..b55c353 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,55 @@ +"""Alembic migration environment.""" +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app.config import settings +from app.database import Base +from app.models import analysis as _analysis # noqa: F401 +from app.models import repo as _repo # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..ef55164 --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,135 @@ +"""Initial AutoDev schema — repositories, analysis reports, issues, refactors.""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +repo_status = postgresql.ENUM( + "pending", "cloning", "analyzing", "refactoring", "validating", "done", "failed", + name="repostatus", + create_type=False, +) +issue_type = postgresql.ENUM( + "complexity", "long_function", "deep_nesting", "duplicate_code", + "unused_import", "long_params", "security", "lint", + name="issuetype", + create_type=False, +) +issue_severity = postgresql.ENUM( + "low", "medium", "high", "critical", + name="issueseverity", + create_type=False, +) +refactor_status = postgresql.ENUM( + "pending", "generated", "validated", "failed", "pr_opened", "rejected", + name="refactorstatus", + create_type=False, +) + + +def upgrade() -> None: + bind = op.get_bind() + repo_status.create(bind, checkfirst=True) + issue_type.create(bind, checkfirst=True) + issue_severity.create(bind, checkfirst=True) + refactor_status.create(bind, checkfirst=True) + + op.create_table( + "repositories", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("url", sa.String(length=512), nullable=False), + sa.Column("owner", sa.String(length=255), nullable=True), + sa.Column("name", sa.String(length=255), nullable=True), + sa.Column("branch", sa.String(length=255), nullable=True), + sa.Column("local_path", sa.String(length=1024), nullable=True), + sa.Column("status", repo_status, nullable=False), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("last_analyzed_at", sa.DateTime(), nullable=True), + sa.Column("task_id", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_repositories_url"), "repositories", ["url"], unique=False) + + op.create_table( + "analysis_reports", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("repo_id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("total_files", sa.Integer(), nullable=False), + sa.Column("total_issues", sa.Integer(), nullable=False), + sa.Column("avg_complexity", sa.Float(), nullable=False), + sa.Column("max_complexity", sa.Integer(), nullable=False), + sa.Column("total_lines", sa.Integer(), nullable=False), + sa.Column("security_issues", sa.Integer(), nullable=False), + sa.Column("lint_errors", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["repo_id"], ["repositories.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "code_issues", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("report_id", sa.String(length=36), nullable=False), + sa.Column("repo_id", sa.String(length=36), nullable=False), + sa.Column("file_path", sa.String(length=1024), nullable=False), + sa.Column("function_name", sa.String(length=512), nullable=True), + sa.Column("line_start", sa.Integer(), nullable=True), + sa.Column("line_end", sa.Integer(), nullable=True), + sa.Column("issue_type", issue_type, nullable=False), + sa.Column("severity", issue_severity, nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("metric_value", sa.Float(), nullable=True), + sa.Column("original_code", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["report_id"], ["analysis_reports.id"]), + sa.ForeignKeyConstraint(["repo_id"], ["repositories.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "refactor_suggestions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("issue_id", sa.String(length=36), nullable=False), + sa.Column("repo_id", sa.String(length=36), nullable=False), + sa.Column("refactored_code", sa.Text(), nullable=True), + sa.Column("explanation", sa.Text(), nullable=True), + sa.Column("complexity_before", sa.Integer(), nullable=True), + sa.Column("complexity_after", sa.Integer(), nullable=True), + sa.Column("lines_before", sa.Integer(), nullable=True), + sa.Column("lines_after", sa.Integer(), nullable=True), + sa.Column("status", refactor_status, nullable=False), + sa.Column("validation_passed", sa.Boolean(), nullable=True), + sa.Column("validation_notes", sa.Text(), nullable=True), + sa.Column("pr_url", sa.String(length=512), nullable=True), + sa.Column("pr_number", sa.Integer(), nullable=True), + sa.Column("branch_name", sa.String(length=255), nullable=True), + sa.Column("tokens_used", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("applied_commit_sha", sa.String(length=40), nullable=True), + sa.Column("source_file_hash", sa.String(length=64), nullable=True), + sa.ForeignKeyConstraint(["issue_id"], ["code_issues.id"]), + sa.ForeignKeyConstraint(["repo_id"], ["repositories.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("issue_id"), + ) + + +def downgrade() -> None: + op.drop_table("refactor_suggestions") + op.drop_table("code_issues") + op.drop_table("analysis_reports") + op.drop_index(op.f("ix_repositories_url"), table_name="repositories") + op.drop_table("repositories") + + bind = op.get_bind() + refactor_status.drop(bind, checkfirst=True) + issue_severity.drop(bind, checkfirst=True) + issue_type.drop(bind, checkfirst=True) + repo_status.drop(bind, checkfirst=True) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..25baefa --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,40 @@ +""" +FastAPI dependencies — authentication and shared guards. +""" +from typing import Optional + +from fastapi import Header, HTTPException, status + +from app.config import get_settings + + +def _extract_api_key( + x_api_key: Optional[str], + authorization: Optional[str], +) -> Optional[str]: + if x_api_key: + return x_api_key.strip() + if authorization and authorization.lower().startswith("bearer "): + return authorization[7:].strip() + return None + + +def require_api_key( + x_api_key: Optional[str] = Header(None, alias="X-API-Key"), + authorization: Optional[str] = Header(None), +) -> None: + """ + Validate API key when API_KEY is configured. + Development: leave API_KEY empty to disable auth. + Production: set API_KEY in environment — all /api/v1/* routes require it. + """ + settings = get_settings() + if not settings.API_KEY: + return + + provided = _extract_api_key(x_api_key, authorization) + if not provided or provided != settings.API_KEY: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key. Pass X-API-Key header or Authorization: Bearer .", + ) diff --git a/backend/app/api/errors.py b/backend/app/api/errors.py new file mode 100644 index 0000000..ef6fdcc --- /dev/null +++ b/backend/app/api/errors.py @@ -0,0 +1,59 @@ +""" +Structured API error responses. +""" +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from slowapi.errors import RateLimitExceeded +from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, HTTP_429_TOO_MANY_REQUESTS + +from app.api.schemas import ErrorDetail, ErrorResponse + +_STATUS_CODES = { + 400: "BAD_REQUEST", + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 409: "CONFLICT", + 422: "VALIDATION_ERROR", + 429: "RATE_LIMITED", + 500: "INTERNAL_ERROR", +} + + +def _error_code(status_code: int) -> str: + return _STATUS_CODES.get(status_code, "ERROR") + + +def _error_body(status_code: int, message: str) -> dict: + return ErrorResponse( + error=ErrorDetail(code=_error_code(status_code), message=message) + ).model_dump() + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(HTTPException) + async def http_exception_handler(_request: Request, exc: HTTPException): + message = exc.detail if isinstance(exc.detail, str) else str(exc.detail) + return JSONResponse( + status_code=exc.status_code, + content=_error_body(exc.status_code, message), + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(_request: Request, exc: RequestValidationError): + messages = [ + f"{'.'.join(str(p) for p in err.get('loc', []))}: {err.get('msg', 'invalid')}" + for err in exc.errors() + ] + return JSONResponse( + status_code=HTTP_422_UNPROCESSABLE_ENTITY, + content=_error_body(HTTP_422_UNPROCESSABLE_ENTITY, "; ".join(messages)), + ) + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(_request: Request, _exc: RateLimitExceeded): + return JSONResponse( + status_code=HTTP_429_TOO_MANY_REQUESTS, + content=_error_body(HTTP_429_TOO_MANY_REQUESTS, "Rate limit exceeded"), + ) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 4db7dcf..2eb4070 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -4,58 +4,76 @@ import os import shutil -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import func from sqlalchemy.orm import Session +from app.api.deps import require_api_key +from app.api.schemas import ( + AnalyzeRequest, + AnalyzeResponse, + DeleteResponse, + IssueResponse, + RefactorQueuedResponse, + RefactorRequest, + RefactorResponse, + ReportResponse, + ReportSummary, + RepoResponse, + StatsResponse, + TaskStatusResponse, +) from app.database import get_db -from app.models.repo import Repository, RepoStatus from app.models.analysis import AnalysisReport, CodeIssue, RefactorSuggestion, RefactorStatus +from app.models.repo import Repository, RepoStatus +from app.rate_limit import limiter +from app.services.task_service import get_task_status from app.tasks.worker import task_full_pipeline, task_refactor_issue import structlog log = structlog.get_logger() -router = APIRouter() - - -# ── Pydantic schemas ────────────────────────────────────────────────────────── -class AnalyzeRequest(BaseModel): - repo_url: str - branch: str = "main" +router = APIRouter(dependencies=[Depends(require_api_key)]) -class AnalyzeResponse(BaseModel): - repo_id: str - task_id: str - status: str - message: str +def _enum_str(value) -> str: + return value.value if hasattr(value, "value") else str(value) -class RefactorRequest(BaseModel): - issue_id: str +def _repo_response(repo: Repository) -> RepoResponse: + return RepoResponse( + id=repo.id, + url=repo.url, + owner=repo.owner, + name=repo.name, + status=_enum_str(repo.status), + branch=repo.branch, + created_at=repo.created_at, + last_analyzed_at=repo.last_analyzed_at, + task_id=repo.task_id, + error_message=repo.error_message, + ) -# ── Repo endpoints ──────────────────────────────────────────────────────────── +# ── Analysis ────────────────────────────────────────────────────────────────── @router.post("/analyze", response_model=AnalyzeResponse, tags=["Analysis"]) -def analyze_repo(req: AnalyzeRequest, db: Session = Depends(get_db)): - """ - Clone repository and kick off full analysis + refactor pipeline. - Returns immediately; processing is async via Celery. - """ - # Check if already being processed +@limiter.limit("10/minute") +def analyze_repo(request: Request, req: AnalyzeRequest, db: Session = Depends(get_db)): + """Queue full clone → analyze → refactor → validate → PR pipeline.""" existing = db.query(Repository).filter( Repository.url == req.repo_url, - Repository.status.in_([RepoStatus.CLONING, RepoStatus.ANALYZING]) + Repository.status.in_([RepoStatus.CLONING, RepoStatus.ANALYZING]), ).first() if existing: - raise HTTPException(409, detail=f"Repo already being processed: {existing.id}") + raise HTTPException( + status_code=409, + detail=f"Repo already being processed: {existing.id}", + ) - # Parse owner/name from URL parts = req.repo_url.rstrip("/").split("/") owner = parts[-2] if len(parts) >= 2 else "unknown" - name = parts[-1].replace(".git", "") if parts else "unknown" + name = parts[-1].replace(".git", "") if parts else "unknown" repo = Repository( url=req.repo_url, @@ -72,7 +90,7 @@ def analyze_repo(req: AnalyzeRequest, db: Session = Depends(get_db)): repo.task_id = task.id db.commit() - log.info("analyze.queued", repo_id=repo.id, url=req.repo_url) + log.info("analyze.queued", repo_id=repo.id, url=req.repo_url, task_id=task.id) return AnalyzeResponse( repo_id=repo.id, task_id=task.id, @@ -81,66 +99,99 @@ def analyze_repo(req: AnalyzeRequest, db: Session = Depends(get_db)): ) -@router.get("/repos", tags=["Repos"]) +@router.get("/tasks/{task_id}", response_model=TaskStatusResponse, tags=["Tasks"]) +def task_status(task_id: str, db: Session = Depends(get_db)): + """Poll Celery task state by ID (links repo_id when available).""" + return get_task_status(task_id, db) + + +# ── Repositories ──────────────────────────────────────────────────────────────── + +@router.get("/repos", response_model=list[RepoResponse], tags=["Repos"]) def list_repos(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)): - repos = db.query(Repository).order_by(Repository.created_at.desc()).offset(skip).limit(limit).all() - return [ - { - "id": r.id, "url": r.url, "owner": r.owner, "name": r.name, - "status": r.status, "branch": r.branch, - "created_at": r.created_at, "last_analyzed_at": r.last_analyzed_at, - } - for r in repos - ] - - -@router.get("/repos/{repo_id}", tags=["Repos"]) + repos = ( + db.query(Repository) + .order_by(Repository.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [_repo_response(r) for r in repos] + + +@router.get("/repos/{repo_id}", response_model=RepoResponse, tags=["Repos"]) def get_repo(repo_id: str, db: Session = Depends(get_db)): repo = db.query(Repository).filter(Repository.id == repo_id).first() if not repo: - raise HTTPException(404, "Repository not found") - return repo + raise HTTPException(status_code=404, detail="Repository not found") + return _repo_response(repo) + + +@router.delete("/repos/{repo_id}", response_model=DeleteResponse, tags=["Repos"]) +def delete_repo(repo_id: str, db: Session = Depends(get_db)): + repo = db.query(Repository).filter(Repository.id == repo_id).first() + if not repo: + raise HTTPException(status_code=404, detail="Repository not found") + + db.query(RefactorSuggestion).filter(RefactorSuggestion.repo_id == repo_id).delete() + db.query(CodeIssue).filter(CodeIssue.repo_id == repo_id).delete() + db.query(AnalysisReport).filter(AnalysisReport.repo_id == repo_id).delete() + db.delete(repo) + db.commit() + + if repo.local_path and os.path.exists(repo.local_path): + try: + shutil.rmtree(repo.local_path) + except OSError: + pass + + return DeleteResponse(deleted=repo_id) + +# ── Reports & refactors ─────────────────────────────────────────────────────── -@router.get("/repos/{repo_id}/report", tags=["Analysis"]) +@router.get("/repos/{repo_id}/report", response_model=ReportResponse, tags=["Analysis"]) def get_report(repo_id: str, db: Session = Depends(get_db)): - report = db.query(AnalysisReport).filter( - AnalysisReport.repo_id == repo_id - ).order_by(AnalysisReport.created_at.desc()).first() + report = ( + db.query(AnalysisReport) + .filter(AnalysisReport.repo_id == repo_id) + .order_by(AnalysisReport.created_at.desc()) + .first() + ) if not report: - raise HTTPException(404, "No analysis report found for this repo") + raise HTTPException(status_code=404, detail="No analysis report found for this repo") issues = db.query(CodeIssue).filter(CodeIssue.report_id == report.id).all() - return { - "report": { - "id": report.id, - "created_at": report.created_at, - "total_files": report.total_files, - "total_issues": report.total_issues, - "avg_complexity": report.avg_complexity, - "max_complexity": report.max_complexity, - "security_issues": report.security_issues, - "lint_errors": report.lint_errors, - }, - "issues": [ - { - "id": i.id, - "file_path": i.file_path, - "function_name": i.function_name, - "issue_type": i.issue_type, - "severity": i.severity, - "description": i.description, - "metric_value": i.metric_value, - "line_start": i.line_start, - "line_end": i.line_end, - "original_code": i.original_code, - } + return ReportResponse( + report=ReportSummary( + id=report.id, + created_at=report.created_at, + total_files=report.total_files, + total_issues=report.total_issues, + avg_complexity=report.avg_complexity, + max_complexity=report.max_complexity, + security_issues=report.security_issues, + lint_errors=report.lint_errors, + ), + issues=[ + IssueResponse( + id=i.id, + file_path=i.file_path, + function_name=i.function_name, + issue_type=_enum_str(i.issue_type), + severity=_enum_str(i.severity), + description=i.description, + metric_value=i.metric_value, + line_start=i.line_start, + line_end=i.line_end, + original_code=i.original_code, + ) for i in issues ], - } + ) -@router.get("/repos/{repo_id}/refactors", tags=["Refactors"]) +@router.get("/repos/{repo_id}/refactors", response_model=list[RefactorResponse], tags=["Refactors"]) def list_refactors(repo_id: str, db: Session = Depends(get_db)): suggestions = db.query(RefactorSuggestion).filter( RefactorSuggestion.repo_id == repo_id @@ -148,92 +199,71 @@ def list_refactors(repo_id: str, db: Session = Depends(get_db)): result = [] for s in suggestions: issue = db.query(CodeIssue).filter(CodeIssue.id == s.issue_id).first() - result.append({ - "id": s.id, - "issue_id": s.issue_id, - "status": s.status, - "complexity_before": s.complexity_before, - "complexity_after": s.complexity_after, - "lines_before": s.lines_before, - "lines_after": s.lines_after, - "validation_passed": s.validation_passed, - "validation_notes": s.validation_notes, - "pr_url": s.pr_url, - "pr_number": s.pr_number, - "tokens_used": s.tokens_used, - "branch_name": s.branch_name, - "explanation": s.explanation, - "refactored_code": s.refactored_code, - "original_code": issue.original_code if issue else None, - "function_name": issue.function_name if issue else None, - "file_path": issue.file_path if issue else None, - "created_at": s.created_at, - }) + result.append( + RefactorResponse( + id=s.id, + issue_id=s.issue_id, + status=_enum_str(s.status), + complexity_before=s.complexity_before, + complexity_after=s.complexity_after, + lines_before=s.lines_before, + lines_after=s.lines_after, + validation_passed=s.validation_passed, + validation_notes=s.validation_notes, + pr_url=s.pr_url, + pr_number=s.pr_number, + tokens_used=s.tokens_used or 0, + branch_name=s.branch_name, + explanation=s.explanation, + refactored_code=s.refactored_code, + original_code=issue.original_code if issue else None, + function_name=issue.function_name if issue else None, + file_path=issue.file_path if issue else None, + created_at=s.created_at, + ) + ) return result -@router.post("/refactor", tags=["Refactors"]) +@router.post("/refactor", response_model=RefactorQueuedResponse, tags=["Refactors"]) def trigger_refactor(req: RefactorRequest, db: Session = Depends(get_db)): - """Manually trigger refactor for a specific issue.""" issue = db.query(CodeIssue).filter(CodeIssue.id == req.issue_id).first() if not issue: - raise HTTPException(404, "Issue not found") + raise HTTPException(status_code=404, detail="Issue not found") task = task_refactor_issue.delay(req.issue_id) - return {"task_id": task.id, "message": "Refactor queued"} + return RefactorQueuedResponse(task_id=task.id, message="Refactor queued") -@router.delete("/repos/{repo_id}", tags=["Repos"]) -def delete_repo(repo_id: str, db: Session = Depends(get_db)): - """Delete a repository and all its associated data.""" - repo = db.query(Repository).filter(Repository.id == repo_id).first() - if not repo: - raise HTTPException(status_code=404, detail="Repository not found") +# ── Dashboard ───────────────────────────────────────────────────────────────── - # Delete in strict dependency order (child before parent) - # 1. RefactorSuggestions reference CodeIssues — must go first - db.query(RefactorSuggestion).filter(RefactorSuggestion.repo_id == repo_id).delete() - # 2. CodeIssues reference AnalysisReports - db.query(CodeIssue).filter(CodeIssue.repo_id == repo_id).delete() - # 3. AnalysisReports reference Repository - db.query(AnalysisReport).filter(AnalysisReport.repo_id == repo_id).delete() - # 4. Finally the repo itself - db.delete(repo) - db.commit() - - # Clean up local clone if it exists - if repo.local_path and os.path.exists(repo.local_path): - try: - shutil.rmtree(repo.local_path) - except Exception: - pass - - return {"deleted": repo_id} - - -@router.get("/stats", tags=["Dashboard"]) +@router.get("/stats", response_model=StatsResponse, tags=["Dashboard"]) def global_stats(db: Session = Depends(get_db)): - """Dashboard-level aggregate stats.""" - from sqlalchemy import func - total_repos = db.query(func.count(Repository.id)).scalar() - total_issues = db.query(func.count(CodeIssue.id)).scalar() - prs_opened = db.query(func.count(RefactorSuggestion.id)).filter( - RefactorSuggestion.status == RefactorStatus.PR_OPENED - ).scalar() - validated = db.query(func.count(RefactorSuggestion.id)).filter( - RefactorSuggestion.validation_passed.is_(True) - ).scalar() + total_repos = db.query(func.count(Repository.id)).scalar() or 0 + total_issues = db.query(func.count(CodeIssue.id)).scalar() or 0 + prs_opened = ( + db.query(func.count(RefactorSuggestion.id)) + .filter(RefactorSuggestion.status == RefactorStatus.PR_OPENED) + .scalar() + or 0 + ) + validated = ( + db.query(func.count(RefactorSuggestion.id)) + .filter(RefactorSuggestion.validation_passed.is_(True)) + .scalar() + or 0 + ) avg_before = db.query(func.avg(RefactorSuggestion.complexity_before)).scalar() or 0 - avg_after = db.query(func.avg(RefactorSuggestion.complexity_after)).scalar() or 0 - - return { - "total_repos": total_repos, - "total_issues": total_issues, - "prs_opened": prs_opened, - "validated_refactors": validated, - "avg_complexity_before": round(float(avg_before), 2), - "avg_complexity_after": round(float(avg_after), 2), - "complexity_reduction_pct": round( + avg_after = db.query(func.avg(RefactorSuggestion.complexity_after)).scalar() or 0 + + return StatsResponse( + total_repos=total_repos, + total_issues=total_issues, + prs_opened=prs_opened, + validated_refactors=validated, + avg_complexity_before=round(float(avg_before), 2), + avg_complexity_after=round(float(avg_after), 2), + complexity_reduction_pct=round( (1 - avg_after / avg_before) * 100 if avg_before else 0, 1 ), - } + ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..cf5bc85 --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,131 @@ +""" +Pydantic request/response schemas for the AutoDev API. +""" +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# ── Requests ────────────────────────────────────────────────────────────────── + +class AnalyzeRequest(BaseModel): + repo_url: str + branch: str = "main" + + +class RefactorRequest(BaseModel): + issue_id: str + + +# ── Responses ───────────────────────────────────────────────────────────────── + +class AnalyzeResponse(BaseModel): + repo_id: str + task_id: str + status: str + message: str + + +class RefactorQueuedResponse(BaseModel): + task_id: str + message: str + + +class DeleteResponse(BaseModel): + deleted: str + + +class RepoResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + url: str + owner: Optional[str] = None + name: Optional[str] = None + status: str + branch: str + created_at: datetime + last_analyzed_at: Optional[datetime] = None + task_id: Optional[str] = None + error_message: Optional[str] = None + + +class ReportSummary(BaseModel): + id: str + created_at: datetime + total_files: int + total_issues: int + avg_complexity: float + max_complexity: int + security_issues: int + lint_errors: int + + +class IssueResponse(BaseModel): + id: str + file_path: str + function_name: Optional[str] = None + issue_type: str + severity: str + description: str + metric_value: Optional[float] = None + line_start: Optional[int] = None + line_end: Optional[int] = None + original_code: Optional[str] = None + + +class ReportResponse(BaseModel): + report: ReportSummary + issues: list[IssueResponse] + + +class RefactorResponse(BaseModel): + id: str + issue_id: str + status: str + complexity_before: Optional[int] = None + complexity_after: Optional[int] = None + lines_before: Optional[int] = None + lines_after: Optional[int] = None + validation_passed: Optional[bool] = None + validation_notes: Optional[str] = None + pr_url: Optional[str] = None + pr_number: Optional[int] = None + tokens_used: int = 0 + branch_name: Optional[str] = None + explanation: Optional[str] = None + refactored_code: Optional[str] = None + original_code: Optional[str] = None + function_name: Optional[str] = None + file_path: Optional[str] = None + created_at: datetime + + +class StatsResponse(BaseModel): + total_repos: int + total_issues: int + prs_opened: int + validated_refactors: int + avg_complexity_before: float + avg_complexity_after: float + complexity_reduction_pct: float + + +class TaskStatusResponse(BaseModel): + task_id: str + status: str = Field(description="Celery state: PENDING, STARTED, SUCCESS, FAILURE, RETRY, REVOKED") + ready: bool + successful: Optional[bool] = None + result: Optional[Any] = None + error: Optional[str] = None + repo_id: Optional[str] = Field(None, description="Linked repository when task is a pipeline job") + + +class ErrorDetail(BaseModel): + code: str + message: str + + +class ErrorResponse(BaseModel): + error: ErrorDetail diff --git a/backend/app/config.py b/backend/app/config.py index 82c9f41..b9cac02 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -49,6 +49,13 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" LOG_FORMAT: str = "json" # "json" | "console" + # ── Security ────────────────────────────────────────────────────────────── + API_KEY: str = "" # When set, all /api/v1 routes require X-API-Key or Bearer token + + # ── Rate limiting ───────────────────────────────────────────────────────── + RATE_LIMIT_ENABLED: bool = True + RATE_LIMIT_DEFAULT: str = "120/minute" + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/db_migrate.py b/backend/app/db_migrate.py new file mode 100644 index 0000000..8921dc9 --- /dev/null +++ b/backend/app/db_migrate.py @@ -0,0 +1,46 @@ +""" +Database migration bootstrap for Docker startup. + +- Fresh DB → alembic upgrade head +- Legacy DB (create_all before Alembic) → stamp head, preserve data +- Already migrated → upgrade head +""" +import subprocess +import sys + +from sqlalchemy import create_engine, inspect, text + +from app.config import settings + + +def _run_alembic(*args: str) -> None: + subprocess.check_call(["alembic", "-c", "alembic.ini", *args]) + + +def bootstrap() -> None: + engine = create_engine(settings.DATABASE_URL) + tables = set(inspect(engine).get_table_names()) + + if "alembic_version" in tables: + with engine.connect() as conn: + row = conn.execute(text("SELECT version_num FROM alembic_version LIMIT 1")).fetchone() + if row: + print(f"Alembic revision {row[0]} — upgrading to head") + _run_alembic("upgrade", "head") + return + + if "repositories" in tables: + print("Legacy schema detected (tables exist, no alembic_version). Stamping head...") + _run_alembic("stamp", "head") + return + + print("Fresh database — running initial migration") + _run_alembic("upgrade", "head") + + +if __name__ == "__main__": + try: + bootstrap() + except Exception as exc: + print(f"Migration bootstrap failed: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/backend/app/main.py b/backend/app/main.py index e1bac53..ba8e0df 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,11 +8,13 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from slowapi.middleware import SlowAPIMiddleware +from app.api.errors import register_exception_handlers +from app.api.routes import router from app.config import settings from app.database import engine, Base -from app.api.routes import router - +from app.rate_limit import limiter def _configure_logging() -> None: level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) @@ -45,11 +47,11 @@ def _configure_logging() -> None: async def lifespan(app: FastAPI): """Startup / shutdown lifecycle.""" log.info("autodev.startup", version="1.0.0", env=settings.ENV) - try: - Base.metadata.create_all(bind=engine) - except Exception as e: - # Enum types already exist on container restart — safe to ignore - log.warning("autodev.db_init_warning", error=str(e)) + if settings.ENV in ("development", "test"): + try: + Base.metadata.create_all(bind=engine) + except Exception as e: + log.warning("autodev.db_init_warning", error=str(e)) yield log.info("autodev.shutdown") @@ -57,10 +59,14 @@ async def lifespan(app: FastAPI): app = FastAPI( title="AutoDev — Self-Healing Codebase Agent", description="Autonomous code analysis, refactoring, and PR automation.", - version="1.0.0", + version="1.1.0", lifespan=lifespan, ) +app.state.limiter = limiter +register_exception_handlers(app) + +app.add_middleware(SlowAPIMiddleware) app.add_middleware( CORSMiddleware, allow_origins=settings.ALLOWED_ORIGINS, diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py new file mode 100644 index 0000000..0b8c25d --- /dev/null +++ b/backend/app/rate_limit.py @@ -0,0 +1,11 @@ +"""Rate limiting configuration (slowapi).""" +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.config import settings + +limiter = Limiter( + key_func=get_remote_address, + default_limits=[settings.RATE_LIMIT_DEFAULT], + enabled=settings.RATE_LIMIT_ENABLED, +) diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..9ecc3d0 --- /dev/null +++ b/backend/app/services/task_service.py @@ -0,0 +1,42 @@ +""" +Celery task status lookups. +""" +from typing import Optional + +from celery.result import AsyncResult +from sqlalchemy.orm import Session + +from app.api.schemas import TaskStatusResponse +from app.models.repo import Repository +from app.tasks.worker import celery_app + + +def get_task_status(task_id: str, db: Optional[Session] = None) -> TaskStatusResponse: + async_result = AsyncResult(task_id, app=celery_app) + ready = async_result.ready() + successful: Optional[bool] = None + result = None + error: Optional[str] = None + + if ready: + successful = async_result.successful() + if successful: + result = async_result.result + else: + error = str(async_result.result) if async_result.result else "Task failed" + + repo_id: Optional[str] = None + if db is not None: + repo = db.query(Repository).filter(Repository.task_id == task_id).first() + if repo: + repo_id = repo.id + + return TaskStatusResponse( + task_id=task_id, + status=async_result.status, + ready=ready, + successful=successful, + result=result, + error=error, + repo_id=repo_id, + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 1c22148..6295dda 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,6 +29,9 @@ requests==2.32.2 # ── Logging ─────────────────────────────────────────────────────────────────── structlog==24.1.0 +# ── Security / rate limiting ────────────────────────────────────────────────── +slowapi==0.1.9 + # ── Testing ─────────────────────────────────────────────────────────────────── pytest==8.2.0 pytest-asyncio==0.23.6 diff --git a/backend/scripts/entrypoint.sh b/backend/scripts/entrypoint.sh new file mode 100644 index 0000000..4750921 --- /dev/null +++ b/backend/scripts/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +cd /app +echo "Running database migrations..." +python -m app.db_migrate + +# If docker-compose passed a command (worker, flower), run it; otherwise start API. +if [ "$#" -gt 0 ]; then + echo "Starting: $*" + exec "$@" +fi + +echo "Starting API server..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 30b0f5d..9756c7c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -25,6 +25,8 @@ os.environ["GITHUB_TOKEN"] = "test-token" os.environ["ENV"] = "test" os.environ["LOG_FORMAT"] = "console" +os.environ["RATE_LIMIT_ENABLED"] = "false" +os.environ["API_KEY"] = "" from app.config import get_settings diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..4ecd5d1 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,61 @@ +"""API authentication tests.""" +import os + +import pytest +from fastapi.testclient import TestClient + +from app.config import get_settings + + +@pytest.fixture +def auth_client(db_session): + os.environ["API_KEY"] = "test-secret-key" + get_settings.cache_clear() + + from app.database import get_db + from app.main import app + + def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + os.environ["API_KEY"] = "" + get_settings.cache_clear() + + +class TestApiKeyAuth: + def test_missing_key_returns_401(self, auth_client): + response = auth_client.get("/api/v1/repos") + assert response.status_code == 401 + body = response.json() + assert body["error"]["code"] == "UNAUTHORIZED" + + def test_invalid_key_returns_401(self, auth_client): + response = auth_client.get( + "/api/v1/repos", + headers={"X-API-Key": "wrong-key"}, + ) + assert response.status_code == 401 + + def test_valid_key_grants_access(self, auth_client): + response = auth_client.get( + "/api/v1/repos", + headers={"X-API-Key": "test-secret-key"}, + ) + assert response.status_code == 200 + assert response.json() == [] + + def test_bearer_token_auth(self, auth_client): + response = auth_client.get( + "/api/v1/repos", + headers={"Authorization": "Bearer test-secret-key"}, + ) + assert response.status_code == 200 + + def test_health_remains_public(self, auth_client): + response = auth_client.get("/health") + assert response.status_code == 200 diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py new file mode 100644 index 0000000..8629288 --- /dev/null +++ b/backend/tests/test_tasks.py @@ -0,0 +1,61 @@ +"""Celery task status API tests.""" +from unittest.mock import MagicMock, patch + +from app.models.repo import Repository, RepoStatus + + +class TestTaskStatus: + @patch("app.services.task_service.AsyncResult") + def test_task_pending(self, mock_async_result, client, db_session): + mock_result = MagicMock() + mock_result.status = "PENDING" + mock_result.ready.return_value = False + mock_async_result.return_value = mock_result + + repo = Repository( + url="https://github.com/acme/demo", + owner="acme", + name="demo", + status=RepoStatus.CLONING, + task_id="task-xyz", + ) + db_session.add(repo) + db_session.commit() + + response = client.get("/api/v1/tasks/task-xyz") + assert response.status_code == 200 + data = response.json() + assert data["task_id"] == "task-xyz" + assert data["status"] == "PENDING" + assert data["ready"] is False + assert data["repo_id"] == repo.id + + @patch("app.services.task_service.AsyncResult") + def test_task_success(self, mock_async_result, client): + mock_result = MagicMock() + mock_result.status = "SUCCESS" + mock_result.ready.return_value = True + mock_result.successful.return_value = True + mock_result.result = {"repo_id": "abc"} + mock_async_result.return_value = mock_result + + response = client.get("/api/v1/tasks/task-done") + assert response.status_code == 200 + data = response.json() + assert data["ready"] is True + assert data["successful"] is True + assert data["result"] == {"repo_id": "abc"} + + @patch("app.services.task_service.AsyncResult") + def test_task_failure(self, mock_async_result, client): + mock_result = MagicMock() + mock_result.status = "FAILURE" + mock_result.ready.return_value = True + mock_result.successful.return_value = False + mock_result.result = Exception("clone failed") + mock_async_result.return_value = mock_result + + response = client.get("/api/v1/tasks/task-fail") + data = response.json() + assert data["successful"] is False + assert "clone failed" in data["error"] diff --git a/docker-compose.yml b/docker-compose.yml index 057dbf4..1bcca35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ version: "3.9" services: - # ── PostgreSQL ────────────────────────────────────────────────────────────── postgres: image: postgres:16-alpine restart: unless-stopped @@ -101,8 +100,10 @@ services: restart: unless-stopped ports: - "3000:3000" + env_file: .env environment: - NEXT_PUBLIC_API_URL: http://localhost:8000/api/v1 + BACKEND_API_URL: http://api:8000/api/v1 + API_KEY: ${API_KEY:-} depends_on: - api diff --git a/frontend/app/api/v1/[...path]/route.ts b/frontend/app/api/v1/[...path]/route.ts new file mode 100644 index 0000000..7b8b560 --- /dev/null +++ b/frontend/app/api/v1/[...path]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BACKEND = + process.env.BACKEND_API_URL || + process.env.NEXT_PUBLIC_API_URL || + "http://localhost:8000/api/v1"; + +const API_KEY = process.env.API_KEY || process.env.NEXT_PUBLIC_API_KEY || ""; + +async function proxy(request: NextRequest, pathSegments: string[]) { + const path = pathSegments.join("/"); + const url = `${BACKEND.replace(/\/$/, "")}/${path}${request.nextUrl.search}`; + + const headers = new Headers(); + const contentType = request.headers.get("content-type"); + if (contentType) headers.set("Content-Type", contentType); + if (API_KEY) headers.set("X-API-Key", API_KEY); + + const init: RequestInit = { method: request.method, headers }; + + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = await request.text(); + } + + const res = await fetch(url, init); + const body = await res.text(); + + return new NextResponse(body, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + }, + }); +} + +type RouteContext = { params: { path: string[] } }; + +export async function GET(request: NextRequest, context: RouteContext) { + return proxy(request, context.params.path); +} + +export async function POST(request: NextRequest, context: RouteContext) { + return proxy(request, context.params.path); +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + return proxy(request, context.params.path); +} diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx index ffc9c13..bda76b8 100644 --- a/frontend/components/dashboard/Dashboard.tsx +++ b/frontend/components/dashboard/Dashboard.tsx @@ -9,7 +9,7 @@ import { RepoSidebar } from "@/components/dashboard/RepoSidebar"; import { RepoDetailPanel } from "@/components/dashboard/RepoDetailPanel"; import { ConfirmModal, Toast } from "@/components/ui/Primitives"; import { useIsMobile } from "@/hooks/useMediaQuery"; -import { api, type Repo, type Stats, type ReportResponse, type Refactor } from "@/lib/api"; +import { api, type Repo, type Stats, type ReportResponse, type Refactor, type TaskStatus } from "@/lib/api"; import { C, type TabId } from "@/lib/theme"; import { errorMessage } from "@/lib/utils"; @@ -41,6 +41,7 @@ export function Dashboard({ initialRepoId }: { initialRepoId?: string }) { const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); const [sidebarOpen, setSidebarOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + const [taskStatus, setTaskStatus] = useState(null); const selectedRepo = repos.find((r) => r.id === selectedRepoId) ?? null; @@ -119,6 +120,31 @@ export function Dashboard({ initialRepoId }: { initialRepoId?: string }) { return () => clearInterval(t); }, [repos, fetchAll]); + useEffect(() => { + const taskId = selectedRepo?.task_id; + if (!taskId || !ACTIVE_STATUSES.includes(selectedRepo?.status ?? "")) { + setTaskStatus(null); + return; + } + + let cancelled = false; + const poll = async () => { + try { + const status = await api.getTaskStatus(taskId); + if (!cancelled) setTaskStatus(status); + } catch { + if (!cancelled) setTaskStatus(null); + } + }; + + poll(); + const t = setInterval(poll, 3000); + return () => { + cancelled = true; + clearInterval(t); + }; + }, [selectedRepo?.task_id, selectedRepo?.status]); + const showToast = (msg: string, type: "success" | "error" = "success") => { setToast({ msg, type }); setTimeout(() => setToast(null), 4000); @@ -307,6 +333,7 @@ export function Dashboard({ initialRepoId }: { initialRepoId?: string }) { refactors={refactors} loading={reportLoading} tab={tab} + taskStatus={taskStatus} onTabChange={handleTabChange} onRetry={() => selectedRepoId && loadReport(selectedRepoId)} onRefactor={handleRefactor} diff --git a/frontend/components/dashboard/RepoDetailPanel.tsx b/frontend/components/dashboard/RepoDetailPanel.tsx index 018aac5..127b47e 100644 --- a/frontend/components/dashboard/RepoDetailPanel.tsx +++ b/frontend/components/dashboard/RepoDetailPanel.tsx @@ -6,7 +6,7 @@ import { IssueTable } from "@/components/dashboard/IssueTable"; import { RefactorList } from "@/components/dashboard/RefactorList"; import { ChartsPanel } from "@/components/dashboard/ChartsPanel"; import { C, SPIN_STYLE, type TabId } from "@/lib/theme"; -import type { ReportResponse, Refactor, Repo } from "@/lib/api"; +import type { ReportResponse, Refactor, Repo, TaskStatus } from "@/lib/api"; export function RepoDetailPanel({ repo, @@ -14,6 +14,7 @@ export function RepoDetailPanel({ refactors, loading, tab, + taskStatus, onTabChange, onRetry, onRefactor, @@ -23,6 +24,7 @@ export function RepoDetailPanel({ refactors: Refactor[]; loading: boolean; tab: TabId; + taskStatus: TaskStatus | null; onTabChange: (tab: TabId) => void; onRetry: () => void; onRefactor: (issueId: string) => Promise; @@ -111,6 +113,21 @@ export function RepoDetailPanel({ return (
+ {taskStatus && !taskStatus.ready && ( +
+ Celery task {taskStatus.task_id.slice(0, 8)}… + {" · "} + {taskStatus.status} +
+ )}
{tabs.map((t) => (