diff --git a/services/hackbot-api/Dockerfile b/services/hackbot-api/Dockerfile new file mode 100644 index 0000000000..8eb3af78b8 --- /dev/null +++ b/services/hackbot-api/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim AS builder + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app +COPY pyproject.toml uv.lock* ./ +RUN uv sync --locked --no-dev || uv sync --no-dev + +FROM python:3.12-slim + +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv +COPY app/ ./app/ + +ENV PYTHONUNBUFFERED=1 +ENV PORT=8080 +ENV PATH="/app/.venv/bin:$PATH" + +EXPOSE 8080 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/services/hackbot-api/app/__init__.py b/services/hackbot-api/app/__init__.py new file mode 100644 index 0000000000..3dc1f76bc6 --- /dev/null +++ b/services/hackbot-api/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/services/hackbot-api/app/config.py b/services/hackbot-api/app/config.py new file mode 100644 index 0000000000..8468bcec34 --- /dev/null +++ b/services/hackbot-api/app/config.py @@ -0,0 +1,29 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Bugzilla + bz_base_url: str = "" + bz_api_key: str = "" + + # Firefox source repo (for bug_fix tool) + source_repo: str = "/workspace/firefox" + + # Agent + model: str | None = None + max_turns: int | None = None + effort: str | None = None + + # Server + port: int = 8080 + environment: str = "development" + sentry_dsn: str | None = None + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "extra": "ignore", + } + + +settings = Settings() diff --git a/services/hackbot-api/app/main.py b/services/hackbot-api/app/main.py new file mode 100644 index 0000000000..e25f4e283d --- /dev/null +++ b/services/hackbot-api/app/main.py @@ -0,0 +1,50 @@ +import logging +from contextlib import asynccontextmanager + +import sentry_sdk +from fastapi import FastAPI + +from app import __version__ +from app.config import settings +from app.routers import duplicate_router, triage_router + +if settings.sentry_dsn: + sentry_sdk.init( + dsn=settings.sentry_dsn, + environment=settings.environment, + release=f"hackbot-api@{__version__}", + send_default_pii=True, + ) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + + +app = FastAPI( + title="Hackbot API", + description="Agentic service to accelerate Firefox development", + version=__version__, + lifespan=lifespan, +) + +app.include_router(triage_router) +app.include_router(duplicate_router) + + +@app.get("/health") +async def health_check(): + """Health check endpoint for Cloud Run.""" + return {"message": "Service is healthy", "status": "ok"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=settings.port) diff --git a/services/hackbot-api/app/routers/__init__.py b/services/hackbot-api/app/routers/__init__.py new file mode 100644 index 0000000000..610164269d --- /dev/null +++ b/services/hackbot-api/app/routers/__init__.py @@ -0,0 +1,4 @@ +from app.routers.duplicate import router as duplicate_router +from app.routers.triage import router as triage_router + +__all__ = ["triage_router", "duplicate_router"] diff --git a/services/hackbot-api/app/routers/duplicate.py b/services/hackbot-api/app/routers/duplicate.py new file mode 100644 index 0000000000..4d428208b8 --- /dev/null +++ b/services/hackbot-api/app/routers/duplicate.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from fastapi import APIRouter + +from app.config import settings +from app.schemas import DuplicateRequest, DuplicateResponse, DuplicateResultItem +from bugbug.tools.duplicate_bugs import DuplicateBugsTool + +router = APIRouter(tags=["duplicate"]) + + +@router.post("/duplicate", response_model=DuplicateResponse) +async def detect_duplicates(request: DuplicateRequest): + tool = DuplicateBugsTool.create() + result = await tool.run( + mode=request.mode, + base_url=settings.bz_base_url, + api_key=settings.bz_api_key, + meta_bug=request.meta_bug, + bug_ids=request.bug_ids, + local_dir=Path(request.local_dir) if request.local_dir else None, + results_dir=Path(request.results_dir) if request.results_dir else None, + model=request.model or settings.model, + max_turns=request.max_turns or settings.max_turns, + ) + return DuplicateResponse( + exit_code=result.exit_code, + results=[ + DuplicateResultItem(name=name, verdict=verdict) + for name, verdict in result.results + ], + ) diff --git a/services/hackbot-api/app/routers/triage.py b/services/hackbot-api/app/routers/triage.py new file mode 100644 index 0000000000..822a401376 --- /dev/null +++ b/services/hackbot-api/app/routers/triage.py @@ -0,0 +1,36 @@ +from pathlib import Path + +from fastapi import APIRouter + +from app.config import settings +from app.schemas import TriageRequest, TriageResponse +from bugbug.tools.bug_fix import BugFixTool + +router = APIRouter(tags=["triage"]) + + +@router.post("/triage", response_model=TriageResponse) +async def triage_bugs(request: TriageRequest): + tool = BugFixTool.create() + result = await tool.run( + base_url=settings.bz_base_url, + api_key=settings.bz_api_key, + source_repo=Path(settings.source_repo), + bugs=request.bugs, + keywords=request.keywords, + blocks=request.blocks, + status=request.status, + instructions=request.instructions, + task=request.task, + rules_dir=Path(request.rules_dir) if request.rules_dir else None, + dry_run=request.dry_run, + newest_first=request.newest_first, + model=request.model or settings.model, + max_turns=request.max_turns or settings.max_turns, + effort=request.effort or settings.effort, + ) + return TriageResponse( + exit_code=result.exit_code, + bugs_processed=result.bugs_processed, + simulated_writes=result.simulated_writes, + ) diff --git a/services/hackbot-api/app/schemas.py b/services/hackbot-api/app/schemas.py new file mode 100644 index 0000000000..746b0ddd29 --- /dev/null +++ b/services/hackbot-api/app/schemas.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel, Field + +# --- Triage --- + + +class TriageRequest(BaseModel): + bugs: list[int] | None = None + keywords: list[str] | None = None + blocks: int | None = None + status: list[str] | None = None + instructions: str = "" + task: str | None = None + rules_dir: str | None = None + dry_run: bool = True # Default to dry-run for safety + newest_first: bool = False + model: str | None = None + max_turns: int | None = None + effort: str | None = None + + +class TriageResponse(BaseModel): + exit_code: int + bugs_processed: int + simulated_writes: list[dict] = Field(default_factory=list) + + +# --- Duplicate Detection --- + + +class DuplicateRequest(BaseModel): + mode: str # "local" | "bugs" | "local_to_local" + meta_bug: int | None = None + bug_ids: list[int] | None = None + local_dir: str | None = None + results_dir: str | None = None + model: str | None = None + max_turns: int | None = None + + +class DuplicateResultItem(BaseModel): + name: str + verdict: str + + +class DuplicateResponse(BaseModel): + exit_code: int + results: list[DuplicateResultItem] diff --git a/services/hackbot-api/pyproject.toml b/services/hackbot-api/pyproject.toml new file mode 100644 index 0000000000..d316f0fede --- /dev/null +++ b/services/hackbot-api/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "hackbot-api" +version = "0.1.0" +description = "Agentic service to accelerate Firefox development" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "pydantic>=2.6.0", + "pydantic-settings>=2.1.0", + "bugsy", + "grizzly-framework", + "prefpicker", + "PyYAML", + "claude-agent-sdk>=0.1.30", + "sentry-sdk>=2.51.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "httpx>=0.26.0"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"]