Skip to content
Draft
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
12 changes: 12 additions & 0 deletions orchestrator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Orchestrator (MVP)
- api: FastAPI service for projects/repos/branches/PRs/runs/analyses, SSE streams
- workers: simple polling workers for Codegen logs and Graph-sitter findings
- web: minimal React/Vite UI for project cards (proxy to /api)

Run API
- see orchestrator/api/README.md

Run Web
- cd orchestrator/web && npm install && npm run dev
- proxy routes to API at http://127.0.0.1:8001

1 change: 1 addition & 0 deletions orchestrator/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

30 changes: 30 additions & 0 deletions orchestrator/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Project Orchestrator API (MVP)

Quick start
1) Python 3.12+ recommended
2) Install deps:
pip install -r orchestrator/api/requirements.txt
3) Set env:
export ORCH_DATABASE_URL=sqlite:///./orchestrator.db
export CODEGEN_API_URL=https://api.codegen.com
export CODEGEN_API_TOKEN=[REDACTED]
export CODEGEN_ORG_ID=[YOUR_ORG_ID]
export GITHUB_TOKEN=[REDACTED]
export GRAPH_SITTER_URL=http://localhost:8080
4) Run server:
uvicorn orchestrator.api.app:app --reload --port 8001

Endpoints (selection)
- GET /health
- POST /projects, GET /projects
- POST /repos, GET /repos, POST /repos/pin, POST /repos/unpin
- GET /branches?repository_id=1, POST /branches
- POST /prs
- POST /runs, POST /runs/{id}/poll, GET /runs/{id}/events/stream (SSE)
- POST /analyses, POST /analyses/{id}/poll, GET /analyses/{id}/stream (SSE)

Notes
- SQLite used for MVP; swap ORCH_DATABASE_URL to Postgres in production.
- SSE endpoints poll DB periodically; wire a worker to call /runs/{id}/poll and /analyses/{id}/poll on intervals.
- Graph-sitter API contract assumed; adapt adapter if your server exposes different routes.

1 change: 1 addition & 0 deletions orchestrator/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

31 changes: 31 additions & 0 deletions orchestrator/api/adapters/analysis_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

import os
from typing import Dict, Any, Optional

import httpx

GRAPH_SITTER_URL = os.getenv("GRAPH_SITTER_URL") # e.g., http://graph-sitter:8000


class AnalysisAdapter:
def __init__(self):
if not GRAPH_SITTER_URL:
raise RuntimeError("GRAPH_SITTER_URL is not set")
self._client = httpx.Client(base_url=GRAPH_SITTER_URL, timeout=60.0)

def start_analysis(self, repo_full_name: str, branch: str, snapshot_digest: Optional[str] = None) -> Dict[str, Any]:
payload = {
"repo": repo_full_name,
"branch": branch,
"snapshot": snapshot_digest,
}
r = self._client.post("/analyze", json=payload)
r.raise_for_status()
return r.json()

def get_findings(self, analysis_id: str, skip: int = 0, limit: int = 100) -> Dict[str, Any]:
r = self._client.get(f"/analyses/{analysis_id}/findings", params={"skip": skip, "limit": limit})
r.raise_for_status()
return r.json()

31 changes: 31 additions & 0 deletions orchestrator/api/adapters/codegen_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

import os
from typing import Any, Dict, List, Optional

import httpx

CODEGEN_API_URL = os.getenv("CODEGEN_API_URL", "https://api.codegen.com")
CODEGEN_API_TOKEN = os.getenv("CODEGEN_API_TOKEN")


class CodegenAdapter:
def __init__(self, org_id: str):
self.org_id = org_id
if not CODEGEN_API_TOKEN:
raise RuntimeError("CODEGEN_API_TOKEN is not set")
self._client = httpx.Client(base_url=CODEGEN_API_URL, headers={
"Authorization": f"Bearer {CODEGEN_API_TOKEN}",
"Content-Type": "application/json",
}, timeout=30.0)

def get_run_logs(self, agent_run_id: str, skip: int = 0, limit: int = 100) -> Dict[str, Any]:
url = f"/v1/organizations/{self.org_id}/agent/run/{agent_run_id}/logs"
params = {"skip": skip, "limit": limit}
resp = self._client.get(url, params=params)
resp.raise_for_status()
return resp.json()

# Placeholder for potential run start / PR creation via Codegen
# def create_pr(...): pass

56 changes: 56 additions & 0 deletions orchestrator/api/adapters/github_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

import os
from typing import Optional, Dict, Any

import httpx

GITHUB_API_URL = os.getenv("GITHUB_API_URL", "https://api.github.com")
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")


class GitHubAdapter:
def __init__(self):
if not GITHUB_TOKEN:
raise RuntimeError("GITHUB_TOKEN is not set")
self._client = httpx.Client(base_url=GITHUB_API_URL, headers={
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
}, timeout=30.0)

def get_repo(self, org: str, name: str) -> Dict[str, Any]:
r = self._client.get(f"/repos/{org}/{name}")
r.raise_for_status()
return r.json()

def get_branch(self, org: str, name: str, branch: str) -> Dict[str, Any]:
r = self._client.get(f"/repos/{org}/{name}/git/ref/heads/{branch}")
r.raise_for_status()
return r.json()

def create_branch(self, org: str, name: str, branch: str, from_ref: Optional[str] = None) -> Dict[str, Any]:
# Determine base SHA
base_branch = from_ref or self.get_repo(org, name).get("default_branch", "main")
ref_data = self.get_branch(org, name, base_branch)
base_sha = ref_data["object"]["sha"]
# Create new ref
payload = {"ref": f"refs/heads/{branch}", "sha": base_sha}
r = self._client.post(f"/repos/{org}/{name}/git/refs", json=payload)
r.raise_for_status()
return r.json()

def create_pr(self, org: str, name: str, head_branch: str, base_branch: str, title: str, body: Optional[str] = None) -> Dict[str, Any]:
payload = {"title": title, "head": head_branch, "base": base_branch, "body": body or ""}
r = self._client.post(f"/repos/{org}/{name}/pulls", json=payload)
r.raise_for_status()
return r.json()

def get_pr_checks(self, org: str, name: str, pr_number: int) -> Dict[str, Any]:
# Use the Checks API indirectly via PR HEAD SHA
pr = self._client.get(f"/repos/{org}/{name}/pulls/{pr_number}")
pr.raise_for_status()
head_sha = pr.json()["head"]["sha"]
checks = self._client.get(f"/repos/{org}/{name}/commits/{head_sha}/check-runs")
checks.raise_for_status()
return checks.json()

45 changes: 45 additions & 0 deletions orchestrator/api/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from __future__ import annotations

import asyncio
from typing import AsyncGenerator

from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from starlette.requests import Request
from starlette.background import BackgroundTasks
from sse_starlette.sse import EventSourceResponse

from .db import Base, engine, get_session
from .routes import projects, repos, branches, prs, runs, analyses

app = FastAPI(title="Project Orchestrator API", version="0.1.0")

# CORS for local dev; tighten in production
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


@app.on_event("startup")
def on_startup() -> None:
Base.metadata.create_all(bind=engine)


# Routers
app.include_router(projects.router, prefix="/projects", tags=["projects"])
app.include_router(repos.router, prefix="/repos", tags=["repos"])
app.include_router(branches.router, prefix="/branches", tags=["branches"])
app.include_router(prs.router, prefix="/prs", tags=["prs"])
app.include_router(runs.router, prefix="/runs", tags=["runs"])
app.include_router(analyses.router, prefix="/analyses", tags=["analyses"])


@app.get("/health")
async def health() -> JSONResponse:
return JSONResponse({"status": "ok"})

31 changes: 31 additions & 0 deletions orchestrator/api/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

import os
from contextlib import contextmanager
from typing import Generator

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base, Session

# Default to SQLite for MVP; swap to Postgres via DATABASE_URL env
DATABASE_URL = os.getenv("ORCH_DATABASE_URL", "sqlite:///./orchestrator.db")

connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, future=True, pool_pre_ping=True, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=Session, future=True)

Base = declarative_base()


@contextmanager
def get_session() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()

Loading