Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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: <key> OR Authorization: Bearer <key>
# 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=<same value as above>
# Docker Compose sets these automatically for the frontend container.

# ── Rate limiting ─────────────────────────────────────────────────────────────
RATE_LIMIT_ENABLED=true
RATE_LIMIT_DEFAULT=120/minute
13 changes: 6 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
FROM python:3.11-slim

# System dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
Expand All @@ -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"]
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`
Expand Down
55 changes: 55 additions & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 26 additions & 0 deletions backend/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -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"}
135 changes: 135 additions & 0 deletions backend/alembic/versions/001_initial_schema.py
Original file line number Diff line number Diff line change
@@ -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)
40 changes: 40 additions & 0 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
@@ -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 <key>.",
)
Loading
Loading