diff --git a/backend/.env.example b/backend/.env.example
index 6f23d69..dbc2873 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -1,32 +1,88 @@
-# API Keys
+# =============================================================================
+# OpenCodeIntel Backend Environment Variables
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# AI & Search APIs
+# -----------------------------------------------------------------------------
+
+# OpenAI - for embeddings and AI features
OPENAI_API_KEY=your_openai_api_key_here
+
+# Pinecone - vector database for semantic search
PINECONE_API_KEY=your_pinecone_api_key_here
PINECONE_INDEX_NAME=codeintel
-# Search V2 - Cohere Reranking (optional but recommended)
+# Cohere - reranking for search quality (optional but recommended)
COHERE_API_KEY=your_cohere_api_key_here
-# Supabase
+# Voyage AI - code-specific embeddings (recommended for code search)
+# Get API key from https://dash.voyageai.com/
+VOYAGE_API_KEY=your_voyage_api_key_here
+
+# -----------------------------------------------------------------------------
+# Supabase - Authentication & Database
+# -----------------------------------------------------------------------------
+
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your_supabase_anon_key_here
SUPABASE_JWT_SECRET=your_jwt_secret_here
-# Backend API
+# Service role key - required for server-side database access (e.g., storing GitHub tokens)
+# Get from Supabase Dashboard → Settings → API → service_role key
+SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
+
+# -----------------------------------------------------------------------------
+# GitHub OAuth - One-Click Repo Import Feature
+# -----------------------------------------------------------------------------
+# IMPORTANT: This is SEPARATE from Supabase GitHub login!
+#
+# Supabase login uses its own OAuth app (configured in Supabase Dashboard).
+# This OAuth app is ONLY for importing repositories from GitHub.
+#
+# Setup:
+# 1. Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
+# 2. Create app with name like "YourApp Repo Import"
+# 3. Set callback URL based on environment:
+# - Development: http://localhost:3000/github/callback
+# - Production: https://yourdomain.com/github/callback
+# 4. Copy Client ID and generate Client Secret
+#
+# You may need separate OAuth apps for dev and production (different callback URLs)
+
+GITHUB_CLIENT_ID=your_github_oauth_app_client_id
+GITHUB_CLIENT_SECRET=your_github_oauth_app_client_secret
+
+# Must match EXACTLY what you set in GitHub OAuth App settings
+GITHUB_REDIRECT_URI=http://localhost:3000/github/callback
+
+# Frontend URL for redirects after OAuth
+FRONTEND_URL=http://localhost:3000
+
+# -----------------------------------------------------------------------------
+# Backend Configuration
+# -----------------------------------------------------------------------------
+
BACKEND_API_URL=http://localhost:8000
API_KEY=dev-secret-key
-# CORS Configuration (Security)
+# CORS - allowed frontend origins (comma-separated for multiple)
ALLOWED_ORIGINS=http://localhost:3000
-# Redis Cache
+# -----------------------------------------------------------------------------
+# Redis Cache (Optional)
+# -----------------------------------------------------------------------------
+
REDIS_HOST=localhost
REDIS_PORT=6379
-# Sentry Error Tracking (Optional)
+# -----------------------------------------------------------------------------
+# Monitoring & Debugging
+# -----------------------------------------------------------------------------
+
+# Sentry error tracking (optional)
# Get DSN from https://sentry.io → Settings → Projects → Client Keys
SENTRY_DSN=
-ENVIRONMENT=development
-# Search V3 - Voyage AI Code Embeddings (recommended for code search)
-# Get API key from https://dash.voyageai.com/
-VOYAGE_API_KEY=your_voyage_api_key_here
+# Environment identifier
+ENVIRONMENT=development
diff --git a/backend/main.py b/backend/main.py
index 624688e..9001f1f 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -27,6 +27,7 @@
from routes.api_keys import router as api_keys_router
from routes.users import router as users_router
from routes.search_v2 import router as search_v2_router
+from routes.github import router as github_router
from routes.ws_playground import websocket_playground_index
from routes.ws_repos import websocket_repo_indexing
@@ -92,6 +93,7 @@ async def dispatch(self, request: Request, call_next):
app.include_router(api_keys_router, prefix=API_PREFIX)
app.include_router(users_router, prefix=API_PREFIX)
app.include_router(search_v2_router, prefix=API_PREFIX)
+app.include_router(github_router, prefix=API_PREFIX)
# WebSocket endpoints (versioned)
app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index)
diff --git a/backend/routes/github.py b/backend/routes/github.py
new file mode 100644
index 0000000..aa1b032
--- /dev/null
+++ b/backend/routes/github.py
@@ -0,0 +1,360 @@
+"""
+GitHub Integration Routes
+Handles OAuth flow and repository listing for one-click import
+
+SECURITY: Token exchange and storage happens server-side only.
+Frontend never sees the GitHub access token.
+"""
+import os
+import re
+import secrets
+import httpx
+from fastapi import APIRouter, HTTPException, Depends, Query
+from fastapi.responses import RedirectResponse
+from typing import Optional
+from pydantic import BaseModel
+from urllib.parse import urlencode
+
+from middleware.auth import require_auth, AuthContext
+from services.github import GitHubService
+from services.observability import logger
+
+
+router = APIRouter(prefix="/github", tags=["GitHub"])
+
+# GitHub OAuth config - load from env
+GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
+GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
+GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "http://localhost:3000/github/callback")
+FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
+
+
+class GitHubStatusResponse(BaseModel):
+ connected: bool
+ username: Optional[str] = None
+ avatar_url: Optional[str] = None
+
+
+class GitHubConnectResponse(BaseModel):
+ auth_url: str
+ state: str
+
+
+class GitHubCallbackRequest(BaseModel):
+ code: str
+ state: str
+
+
+class GitHubRepoResponse(BaseModel):
+ id: int
+ name: str
+ full_name: str
+ description: Optional[str]
+ html_url: str
+ clone_url: str
+ default_branch: str
+ private: bool
+ fork: bool
+ stars: int
+ language: Optional[str]
+ size_kb: int
+ owner: str
+ owner_avatar: str
+
+
+async def _get_github_connection(user_id: str) -> Optional[dict]:
+ """Get user's GitHub connection from database"""
+ try:
+ from services.supabase_service import get_supabase_service
+ db = get_supabase_service().client
+ result = db.table("github_connections").select("*").eq("user_id", user_id).execute()
+ return result.data[0] if result.data else None
+ except Exception as e:
+ logger.error("Failed to get GitHub connection", error=str(e), user_id=user_id)
+ return None
+
+
+async def _save_github_connection(
+ user_id: str,
+ access_token: str,
+ github_user_id: int,
+ github_username: str,
+ github_avatar_url: Optional[str],
+ scope: str
+) -> bool:
+ """Save or update GitHub connection in database"""
+ try:
+ from services.supabase_service import get_supabase_service
+ db = get_supabase_service().client
+
+ data = {
+ "user_id": user_id,
+ "access_token": access_token,
+ "github_user_id": github_user_id,
+ "github_username": github_username,
+ "github_avatar_url": github_avatar_url,
+ "token_scope": scope,
+ }
+
+ db.table("github_connections").upsert(data, on_conflict="user_id").execute()
+ return True
+ except Exception as e:
+ logger.error("Failed to save GitHub connection", error=str(e), user_id=user_id)
+ return False
+
+
+async def _delete_github_connection(user_id: str) -> bool:
+ """Remove GitHub connection"""
+ try:
+ from services.supabase_service import get_supabase_service
+ db = get_supabase_service().client
+ db.table("github_connections").delete().eq("user_id", user_id).execute()
+ return True
+ except Exception as e:
+ logger.error("Failed to delete GitHub connection", error=str(e), user_id=user_id)
+ return False
+
+
+async def _update_last_used(user_id: str) -> None:
+ """Update last_used_at timestamp"""
+ try:
+ from datetime import datetime, timezone
+ from services.supabase_service import get_supabase_service
+ db = get_supabase_service().client
+ db.table("github_connections").update(
+ {"last_used_at": datetime.now(timezone.utc).isoformat()}
+ ).eq("user_id", user_id).execute()
+ except Exception:
+ pass
+
+
+@router.get("/status", response_model=GitHubStatusResponse)
+async def get_github_status(auth: AuthContext = Depends(require_auth)):
+ """Check if user has GitHub connected and token is valid"""
+ if not auth.user_id:
+ raise HTTPException(status_code=401, detail="User ID required")
+
+ connection = await _get_github_connection(auth.user_id)
+ if not connection:
+ return GitHubStatusResponse(connected=False)
+
+ # Verify token is still valid by making a test API call
+ try:
+ github = GitHubService(connection["access_token"])
+ is_valid = await github.validate_token()
+
+ if not is_valid:
+ # Token expired or revoked, clean up
+ await _delete_github_connection(auth.user_id)
+ return GitHubStatusResponse(connected=False)
+
+ return GitHubStatusResponse(
+ connected=True,
+ username=connection.get("github_username"),
+ avatar_url=connection.get("github_avatar_url")
+ )
+ except Exception as e:
+ logger.warning("GitHub token validation failed", error=str(e))
+ return GitHubStatusResponse(connected=False)
+
+
+@router.get("/connect", response_model=GitHubConnectResponse)
+async def initiate_github_connect(auth: AuthContext = Depends(require_auth)):
+ """
+ Start GitHub OAuth flow for repo import
+
+ Returns URL to redirect user to GitHub authorization page.
+ Frontend should redirect user to this URL.
+ """
+ if not auth.user_id:
+ raise HTTPException(status_code=401, detail="User ID required")
+
+ if not GITHUB_CLIENT_ID:
+ raise HTTPException(status_code=500, detail="GitHub OAuth not configured")
+
+ # Generate state token to prevent CSRF
+ # In production, store this in Redis/DB with expiry and user_id association
+ state = f"{auth.user_id}:{secrets.token_urlsafe(32)}"
+
+ params = {
+ "client_id": GITHUB_CLIENT_ID,
+ "redirect_uri": GITHUB_REDIRECT_URI,
+ "scope": "repo", # Full repo access for private repos
+ "state": state,
+ "allow_signup": "false", # User should already have GitHub account
+ }
+
+ auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
+
+ return GitHubConnectResponse(auth_url=auth_url, state=state)
+
+
+@router.post("/callback")
+async def github_oauth_callback(
+ request: GitHubCallbackRequest,
+ auth: AuthContext = Depends(require_auth)
+):
+ """
+ Handle GitHub OAuth callback
+
+ Exchanges authorization code for access token and stores it.
+ Called by frontend after GitHub redirects back.
+ """
+ if not auth.user_id:
+ raise HTTPException(status_code=401, detail="User ID required")
+
+ if not GITHUB_CLIENT_ID or not GITHUB_CLIENT_SECRET:
+ raise HTTPException(status_code=500, detail="GitHub OAuth not configured")
+
+ # Verify state format and user_id match
+ # State format: user_id:random_token (where random_token is base64url from token_urlsafe)
+ state_parts = request.state.split(":", 1)
+ if len(state_parts) != 2:
+ raise HTTPException(status_code=400, detail="Invalid state format")
+
+ state_user_id, state_token = state_parts
+ if state_user_id != auth.user_id:
+ raise HTTPException(status_code=400, detail="State user mismatch")
+
+ # Validate token portion: token_urlsafe(32) produces 43 chars of URL-safe base64
+ if len(state_token) != 43:
+ raise HTTPException(status_code=400, detail="Invalid state token length")
+
+ # Validate charset (URL-safe base64: A-Z, a-z, 0-9, -, _)
+ if not re.match(r'^[A-Za-z0-9_-]+$', state_token):
+ raise HTTPException(status_code=400, detail="Invalid state token charset")
+
+ # Exchange code for access token
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ "https://github.com/login/oauth/access_token",
+ data={
+ "client_id": GITHUB_CLIENT_ID,
+ "client_secret": GITHUB_CLIENT_SECRET,
+ "code": request.code,
+ "redirect_uri": GITHUB_REDIRECT_URI,
+ },
+ headers={"Accept": "application/json"},
+ timeout=30.0
+ )
+
+ if response.status_code != 200:
+ logger.error("GitHub token exchange failed", status=response.status_code)
+ raise HTTPException(status_code=400, detail="Failed to exchange code for token")
+
+ token_data = response.json()
+
+ if "error" in token_data:
+ logger.error("GitHub OAuth error", error=token_data.get("error_description"))
+ raise HTTPException(status_code=400, detail=token_data.get("error_description", "OAuth failed"))
+
+ access_token = token_data.get("access_token")
+ scope = token_data.get("scope", "")
+
+ if not access_token:
+ raise HTTPException(status_code=400, detail="No access token received")
+
+ # Get GitHub user info
+ github = GitHubService(access_token)
+ user_info = await github.get_user()
+
+ if not user_info:
+ raise HTTPException(status_code=400, detail="Failed to get GitHub user info")
+
+ # Save connection to database
+ saved = await _save_github_connection(
+ user_id=auth.user_id,
+ access_token=access_token,
+ github_user_id=user_info.id,
+ github_username=user_info.login,
+ github_avatar_url=user_info.avatar_url,
+ scope=scope
+ )
+
+ if not saved:
+ raise HTTPException(status_code=500, detail="Failed to save GitHub connection")
+
+ logger.info("GitHub connected successfully", user_id=auth.user_id, github_user=user_info.login)
+
+ return {
+ "success": True,
+ "username": user_info.login,
+ "avatar_url": user_info.avatar_url
+ }
+
+
+@router.delete("/disconnect")
+async def disconnect_github(auth: AuthContext = Depends(require_auth)):
+ """Remove GitHub connection"""
+ if not auth.user_id:
+ raise HTTPException(status_code=401, detail="User ID required")
+
+ deleted = await _delete_github_connection(auth.user_id)
+ return {"success": deleted}
+
+
+@router.get("/repos", response_model=list[GitHubRepoResponse])
+async def list_github_repos(
+ auth: AuthContext = Depends(require_auth),
+ include_forks: bool = False,
+ page: int = Query(default=1, ge=1),
+ per_page: int = Query(default=50, ge=1, le=100)
+):
+ """
+ List user's GitHub repositories for import selection
+
+ Returns repos sorted by last updated, excludes forks by default.
+ Includes both personal repos and org repos user has access to.
+ """
+ if not auth.user_id:
+ raise HTTPException(status_code=401, detail="User ID required")
+
+ connection = await _get_github_connection(auth.user_id)
+ if not connection:
+ raise HTTPException(
+ status_code=400,
+ detail="GitHub not connected. Please connect your GitHub account first."
+ )
+
+ try:
+ github = GitHubService(connection["access_token"])
+ repos = await github.get_repos(
+ include_forks=include_forks,
+ per_page=per_page,
+ page=page
+ )
+
+ # Update last used timestamp
+ await _update_last_used(auth.user_id)
+
+ return [
+ GitHubRepoResponse(
+ id=repo.id,
+ name=repo.name,
+ full_name=repo.full_name,
+ description=repo.description,
+ html_url=repo.html_url,
+ clone_url=repo.clone_url,
+ default_branch=repo.default_branch,
+ private=repo.private,
+ fork=repo.fork,
+ stars=repo.stargazers_count,
+ language=repo.language,
+ size_kb=repo.size,
+ owner=repo.owner_login,
+ owner_avatar=repo.owner_avatar
+ )
+ for repo in repos
+ ]
+ except Exception as e:
+ logger.error("Failed to fetch GitHub repos", error=str(e), user_id=auth.user_id)
+
+ # Check if token was revoked
+ if "401" in str(e) or "Bad credentials" in str(e):
+ await _delete_github_connection(auth.user_id)
+ raise HTTPException(
+ status_code=401,
+ detail="GitHub access revoked. Please reconnect your GitHub account."
+ )
+
+ raise HTTPException(status_code=500, detail="Failed to fetch repositories")
diff --git a/backend/services/github.py b/backend/services/github.py
new file mode 100644
index 0000000..3fe6183
--- /dev/null
+++ b/backend/services/github.py
@@ -0,0 +1,194 @@
+"""
+GitHub API Service
+Handles fetching user repositories and validating tokens
+"""
+import httpx
+from typing import Optional
+from dataclasses import dataclass
+from services.observability import logger
+
+GITHUB_API_BASE = "https://api.github.com"
+
+
+@dataclass
+class GitHubRepo:
+ id: int
+ name: str
+ full_name: str
+ description: Optional[str]
+ html_url: str
+ clone_url: str
+ ssh_url: str
+ default_branch: str
+ private: bool
+ fork: bool
+ stargazers_count: int
+ language: Optional[str]
+ size: int # in KB
+ owner_login: str
+ owner_avatar: str
+
+
+@dataclass
+class GitHubUser:
+ login: str
+ id: int
+ avatar_url: Optional[str]
+ name: Optional[str]
+
+
+class GitHubService:
+ """Wrapper for GitHub API calls using user's OAuth token"""
+
+ def __init__(self, access_token: str):
+ self.token = access_token
+ self.headers = {
+ "Authorization": f"Bearer {access_token}",
+ "Accept": "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28"
+ }
+
+ async def validate_token(self) -> bool:
+ """Check if the token is valid by fetching user info"""
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{GITHUB_API_BASE}/user",
+ headers=self.headers,
+ timeout=10.0
+ )
+ return response.status_code == 200
+ except Exception:
+ return False
+
+ async def get_user(self) -> Optional[GitHubUser]:
+ """Get authenticated user info"""
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{GITHUB_API_BASE}/user",
+ headers=self.headers,
+ timeout=10.0
+ )
+ if response.status_code != 200:
+ return None
+ data = response.json()
+ return GitHubUser(
+ login=data.get("login", ""),
+ id=data.get("id", 0),
+ avatar_url=data.get("avatar_url"),
+ name=data.get("name")
+ )
+ except (httpx.RequestError, httpx.TimeoutException, KeyError, ValueError) as e:
+ logger.error("Failed to fetch GitHub user", error=str(e))
+ return None
+
+ async def get_repos(
+ self,
+ include_forks: bool = False,
+ include_private: bool = True,
+ per_page: int = 100,
+ page: int = 1
+ ) -> list[GitHubRepo]:
+ """
+ Fetch user's repositories (owned + accessible from orgs)
+
+ Uses /user/repos which returns repos the user has explicit access to,
+ including personal repos and org repos where user is a member
+
+ Raises exceptions on failure so callers can distinguish errors from empty results.
+ """
+ try:
+ async with httpx.AsyncClient() as client:
+ params = {
+ "visibility": "all" if include_private else "public",
+ "affiliation": "owner,organization_member",
+ "sort": "updated",
+ "direction": "desc",
+ "per_page": per_page,
+ "page": page
+ }
+ response = await client.get(
+ f"{GITHUB_API_BASE}/user/repos",
+ headers=self.headers,
+ params=params,
+ timeout=30.0
+ )
+ if response.status_code != 200:
+ logger.error(
+ "GitHub API error fetching repos",
+ status_code=response.status_code,
+ page=page
+ )
+ raise RuntimeError(f"GitHub API error: {response.status_code}")
+
+ repos = []
+ for repo in response.json():
+ if not include_forks and repo.get("fork", False):
+ continue
+ repos.append(GitHubRepo(
+ id=repo.get("id", 0),
+ name=repo.get("name", ""),
+ full_name=repo.get("full_name", ""),
+ description=repo.get("description"),
+ html_url=repo.get("html_url", ""),
+ clone_url=repo.get("clone_url", ""),
+ ssh_url=repo.get("ssh_url", ""),
+ default_branch=repo.get("default_branch", "main"),
+ private=repo.get("private", False),
+ fork=repo.get("fork", False),
+ stargazers_count=repo.get("stargazers_count", 0),
+ language=repo.get("language"),
+ size=repo.get("size", 0),
+ owner_login=repo.get("owner", {}).get("login", ""),
+ owner_avatar=repo.get("owner", {}).get("avatar_url", "")
+ ))
+ return repos
+ except (httpx.RequestError, httpx.TimeoutException) as e:
+ logger.error("Network error fetching GitHub repos", error=str(e), page=page)
+ raise
+ except (KeyError, ValueError, TypeError) as e:
+ logger.error("Failed to parse GitHub repos response", error=str(e), page=page)
+ raise
+
+ async def get_all_repos(
+ self,
+ include_forks: bool = False,
+ max_pages: Optional[int] = 10,
+ per_page: int = 100
+ ) -> list[GitHubRepo]:
+ """
+ Fetch all repos with pagination
+
+ Args:
+ include_forks: Whether to include forked repos
+ max_pages: Maximum pages to fetch (None for no limit, default 10)
+ per_page: Results per page (default 100)
+
+ Raises exceptions on failure - does not mask errors as empty results.
+ """
+ all_repos = []
+ page = 1
+ while True:
+ repos = await self.get_repos(
+ include_forks=include_forks,
+ per_page=per_page,
+ page=page
+ )
+ all_repos.extend(repos)
+
+ # Stop when we get fewer results than requested (last page)
+ if len(repos) < per_page:
+ break
+
+ page += 1
+
+ if max_pages is not None and page > max_pages:
+ logger.warning(
+ "GitHub repo pagination stopped at limit",
+ max_pages=max_pages,
+ total_repos_fetched=len(all_repos),
+ last_page_fetched=page - 1
+ )
+ break
+ return all_repos
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 729866f..dc68ffe 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import { LandingPage } from './pages/LandingPage';
import { Dashboard } from './components/Dashboard';
import { DocsHomePage } from './pages/DocsHomePage';
import { MCPSetupPage } from './pages/MCPSetupPage';
+import { GitHubCallbackPage } from './pages/GitHubCallbackPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
@@ -67,6 +68,16 @@ function AppRoutes() {
+ {status?.connected ? `Connected as ${status.username}` : 'Connect to import repositories'}
+
+ Grant access to your repositories for one-click import. We only read repository metadata.
+
+ You can revoke access anytime in{' '}
+
+ GitHub Settings {error}
+ You've reached your repository limit. Upgrade to add more.
+ {error}
+ {searchQuery ? 'No repositories match your search' : 'No repositories found'}
+
+ {selected.size} selected • {Math.max(0, remainingSlots - selected.size)} slots remaining
+ Import from GitHub
+ Connect Your GitHub
+
Please wait while we complete the connection
+ > + )} + + {status === 'success' && ( + <> +Redirecting to dashboard...
+ > + )} + + {status === 'error' && ( + <> +{errorMessage}
+ + > + )} +