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() { } /> } /> + {/* GitHub OAuth Callback - Protected, user must be logged in */} + + + + } + /> + {/* Placeholder routes for future docs pages */} } /> } /> diff --git a/frontend/src/components/GitHubRepoSelector.tsx b/frontend/src/components/GitHubRepoSelector.tsx new file mode 100644 index 0000000..2a80955 --- /dev/null +++ b/frontend/src/components/GitHubRepoSelector.tsx @@ -0,0 +1,268 @@ +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Github, Search, Lock, Star, Loader2, X, Check, AlertCircle, ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useGitHubRepos, GitHubRepo } from '@/hooks/useGitHubRepos'; + +interface GitHubRepoSelectorProps { + isOpen: boolean; + onClose: () => void; + onImport: (repos: GitHubRepo[]) => void; + maxSelectable?: number; + currentRepoCount?: number; +} + +export function GitHubRepoSelector({ + isOpen, + onClose, + onImport, + maxSelectable = 3, + currentRepoCount = 0, +}: GitHubRepoSelectorProps) { + const { repos, status, loading, error, checkStatus, fetchRepos, initiateConnect, clearError } = useGitHubRepos(); + const [selected, setSelected] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [connecting, setConnecting] = useState(false); + const hasFetchedReposRef = useRef(false); + + const remainingSlots = maxSelectable - currentRepoCount; + + useEffect(() => { + if (isOpen) { + checkStatus(); + setSelected(new Set()); + setSearchQuery(''); + clearError(); + hasFetchedReposRef.current = false; + } + }, [isOpen, checkStatus, clearError]); + + useEffect(() => { + // Only fetch once per modal open, after status confirms connected + if (isOpen && status?.connected && !hasFetchedReposRef.current) { + hasFetchedReposRef.current = true; + fetchRepos(); + } + }, [isOpen, status?.connected, fetchRepos]); + + const handleConnect = async () => { + setConnecting(true); + try { + const authUrl = await initiateConnect(); + if (authUrl) { + // Redirect to GitHub OAuth + window.location.href = authUrl; + } + } catch (err) { + console.error('Failed to initiate GitHub connection:', err); + } finally { + setConnecting(false); + } + }; + + const toggleSelect = (repoId: number) => { + const newSelected = new Set(selected); + if (newSelected.has(repoId)) { + newSelected.delete(repoId); + } else if (newSelected.size < remainingSlots) { + newSelected.add(repoId); + } + setSelected(newSelected); + }; + + const handleImport = () => { + const selectedRepos = repos.filter(r => selected.has(r.id)); + onImport(selectedRepos); + setSelected(new Set()); + onClose(); + }; + + const filteredRepos = repos.filter(repo => + repo.name.toLowerCase().includes(searchQuery.toLowerCase()) || + repo.full_name.toLowerCase().includes(searchQuery.toLowerCase()) || + (repo.description?.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + if (!isOpen) return null; + + return ( + + + e.stopPropagation()} + className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col" + > + {/* Header */} +
+
+
+ +
+
+

Import from GitHub

+

+ {status?.connected ? `Connected as ${status.username}` : 'Connect to import repositories'} +

+
+
+ +
+ + {/* Content */} +
+ {!status?.connected ? ( +
+
+ +
+

Connect Your GitHub

+

+ Grant access to your repositories for one-click import. We only read repository metadata. +

+

+ You can revoke access anytime in{' '} + + GitHub Settings + +

+ + {error && ( +

{error}

+ )} +
+ ) : ( + <> + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ {remainingSlots <= 0 && ( +

+ You've reached your repository limit. Upgrade to add more. +

+ )} +
+ + {/* Repo List */} +
+ {loading && repos.length === 0 ? ( +
+ +
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : filteredRepos.length === 0 ? ( +
+

+ {searchQuery ? 'No repositories match your search' : 'No repositories found'} +

+
+ ) : ( +
+ {filteredRepos.map(repo => { + const isSelected = selected.has(repo.id); + const canSelect = remainingSlots > 0 && (isSelected || selected.size < remainingSlots); + + return ( + + ); + })} +
+ )} +
+ + )} +
+ + {/* Footer */} + {status?.connected && ( +
+

+ {selected.size} selected • {Math.max(0, remainingSlots - selected.size)} slots remaining +

+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardHome.tsx b/frontend/src/components/dashboard/DashboardHome.tsx index fbbb8ee..43d4347 100644 --- a/frontend/src/components/dashboard/DashboardHome.tsx +++ b/frontend/src/components/dashboard/DashboardHome.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { useSearchParams } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import { toast } from 'sonner' import { @@ -10,12 +11,14 @@ import { ArrowLeft, FolderGit2, ExternalLink, - Plus + Plus, + Github } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { Button } from '../ui/button' import { RepoList } from '../RepoList' import { AddRepoForm } from '../AddRepoForm' +import { GitHubRepoSelector } from '../GitHubRepoSelector' import { SearchPanel } from '../SearchPanel' import { DependencyGraph } from '../DependencyGraph' import { RepoOverview } from '../RepoOverview' @@ -24,24 +27,39 @@ import { ImpactAnalyzer } from '../ImpactAnalyzer' import { DashboardStats } from './DashboardStats' import { IndexingProgressModal } from '../IndexingProgressModal' import type { Repository } from '../../types' +import type { GitHubRepo } from '../../hooks/useGitHubRepos' import { API_URL } from '../../config/api' +const MAX_FREE_REPOS = 3 + type RepoTab = 'overview' | 'search' | 'dependencies' | 'insights' | 'impact' export function DashboardHome() { const { session } = useAuth() + const [searchParams, setSearchParams] = useSearchParams() const [repos, setRepos] = useState([]) const [selectedRepo, setSelectedRepo] = useState(null) const [activeTab, setActiveTab] = useState('overview') const [loading, setLoading] = useState(false) const [reposLoading, setReposLoading] = useState(true) const [showAddForm, setShowAddForm] = useState(false) + const [showGitHubSelector, setShowGitHubSelector] = useState(false) // Indexing progress modal state const [indexingRepoId, setIndexingRepoId] = useState(null) const [indexingRepoName, setIndexingRepoName] = useState('') const [showIndexingModal, setShowIndexingModal] = useState(false) + // Auto-open GitHub import modal if redirected from OAuth callback + useEffect(() => { + if (searchParams.get('openGitHubImport') === 'true') { + setShowGitHubSelector(true) + // Clear the param from URL without triggering navigation + searchParams.delete('openGitHubImport') + setSearchParams(searchParams, { replace: true }) + } + }, [searchParams, setSearchParams]) + const fetchRepos = async () => { if (!session?.access_token) return try { @@ -110,6 +128,67 @@ export function DashboardHome() { } } + const handleGitHubImport = async (githubRepos: GitHubRepo[]) => { + if (githubRepos.length === 0) return + + // Import repos one at a time + for (const repo of githubRepos) { + try { + setLoading(true) + const response = await fetch(`${API_URL}/repos`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session?.access_token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: repo.name, + git_url: repo.clone_url, + branch: repo.default_branch + }) + }) + + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.detail || `Failed to add ${repo.name}`) + } + + const data = await response.json() + if (!data.repo_id) throw new Error('Missing repo_id in response') + + // Trigger async indexing + const indexResponse = await fetch(`${API_URL}/repos/${data.repo_id}/index/async`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${session?.access_token}` } + }) + + if (!indexResponse.ok) { + const err = await indexResponse.json().catch(() => ({})) + const errMsg = err.detail?.message || err.detail || 'Indexing failed to start' + console.error(`Failed to start indexing for ${repo.name}:`, err) + toast.warning(`${repo.name} added but indexing failed`, { + description: errMsg + }) + } else if (repo === githubRepos[githubRepos.length - 1]) { + // Only show progress modal for last repo if indexing started successfully + setIndexingRepoId(data.repo_id) + setIndexingRepoName(repo.name) + setShowIndexingModal(true) + } + + toast.success(`Added ${repo.name}`) + } catch (error) { + console.error(`Error importing ${repo.name}:`, error) + toast.error(`Failed to import ${repo.name}`, { + description: error instanceof Error ? error.message : 'Please try again' + }) + } + } + + setLoading(false) + await fetchRepos() + } + const handleIndexingComplete = async () => { await fetchRepos() toast.success('Indexing complete!', { description: `${indexingRepoName} is ready for search` }) @@ -195,14 +274,25 @@ export function DashboardHome() { Semantic code search powered by AI

- +
+ + +
+ + {/* GitHub Repo Selector */} + setShowGitHubSelector(false)} + onImport={handleGitHubImport} + maxSelectable={MAX_FREE_REPOS} + currentRepoCount={repos.length} + /> ) } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index c2b3f63..d0b719b 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -22,6 +22,9 @@ const supabase: SupabaseClient = createClient( import.meta.env.VITE_SUPABASE_ANON_KEY || '' ); +// Export for direct access when needed +export { supabase }; + export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [session, setSession] = useState(null); diff --git a/frontend/src/hooks/useGitHubRepos.ts b/frontend/src/hooks/useGitHubRepos.ts new file mode 100644 index 0000000..47ede2a --- /dev/null +++ b/frontend/src/hooks/useGitHubRepos.ts @@ -0,0 +1,223 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + +export interface GitHubRepo { + id: number; + name: string; + full_name: string; + description: string | null; + html_url: string; + clone_url: string; + default_branch: string; + private: boolean; + fork: boolean; + stars: number; + language: string | null; + size_kb: number; + owner: string; + owner_avatar: string; +} + +export interface GitHubStatus { + connected: boolean; + username: string | null; + avatar_url: string | null; +} + +export function useGitHubRepos() { + const { session } = useAuth(); + const [repos, setRepos] = useState([]); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Extract access_token to stabilize dependencies + const accessToken = session?.access_token; + + const headers = useMemo(() => { + if (!accessToken) return null; + return { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }; + }, [accessToken]); + + const checkStatus = useCallback(async () => { + if (!headers) { + setStatus({ connected: false, username: null, avatar_url: null }); + return { connected: false, username: null, avatar_url: null }; + } + + try { + setLoading(true); + setError(null); + const response = await fetch(`${API_URL}/api/v1/github/status`, { headers }); + + if (!response.ok) { + throw new Error('Failed to check GitHub status'); + } + + const data = await response.json(); + setStatus(data); + return data; + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + const fallback = { connected: false, username: null, avatar_url: null }; + setStatus(fallback); + return fallback; + } finally { + setLoading(false); + } + }, [headers]); + + const initiateConnect = useCallback(async () => { + if (!headers) { + setError('Not authenticated'); + return null; + } + + try { + setLoading(true); + setError(null); + const response = await fetch(`${API_URL}/api/v1/github/connect`, { headers }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || 'Failed to initiate GitHub connection'); + } + + const data = await response.json(); + sessionStorage.setItem('github_oauth_state', data.state); + return data.auth_url; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + return null; + } finally { + setLoading(false); + } + }, [headers]); + + const completeConnect = useCallback(async (code: string, state: string) => { + if (!headers) { + setError('Not authenticated'); + return false; + } + + const storedState = sessionStorage.getItem('github_oauth_state'); + if (state !== storedState) { + setError('Invalid OAuth state - possible CSRF attack'); + return false; + } + + try { + setLoading(true); + setError(null); + const response = await fetch(`${API_URL}/api/v1/github/callback`, { + method: 'POST', + headers, + body: JSON.stringify({ code, state }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || 'Failed to complete GitHub connection'); + } + + const data = await response.json(); + sessionStorage.removeItem('github_oauth_state'); + + setStatus({ + connected: true, + username: data.username, + avatar_url: data.avatar_url, + }); + + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + return false; + } finally { + setLoading(false); + } + }, [headers]); + + const disconnect = useCallback(async () => { + if (!headers) return false; + + try { + setLoading(true); + setError(null); + const response = await fetch(`${API_URL}/api/v1/github/disconnect`, { + method: 'DELETE', + headers, + }); + + if (!response.ok) { + throw new Error('Failed to disconnect GitHub'); + } + + setStatus({ connected: false, username: null, avatar_url: null }); + setRepos([]); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + return false; + } finally { + setLoading(false); + } + }, [headers]); + + const fetchRepos = useCallback(async (page = 1, includeForks = false) => { + if (!headers) { + setError('Not authenticated'); + return []; + } + + try { + setLoading(true); + setError(null); + const params = new URLSearchParams({ + page: page.toString(), + per_page: '50', + include_forks: includeForks.toString(), + }); + + const response = await fetch(`${API_URL}/api/v1/github/repos?${params}`, { headers }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || 'Failed to fetch repositories'); + } + + const data = await response.json(); + setRepos(prev => page === 1 ? data : [...prev, ...data]); + return data; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + return []; + } finally { + setLoading(false); + } + }, [headers]); + + const clearError = useCallback(() => setError(null), []); + + return { + repos, + status, + loading, + error, + checkStatus, + initiateConnect, + completeConnect, + disconnect, + fetchRepos, + clearError, + }; +} diff --git a/frontend/src/pages/GitHubCallbackPage.tsx b/frontend/src/pages/GitHubCallbackPage.tsx new file mode 100644 index 0000000..838df21 --- /dev/null +++ b/frontend/src/pages/GitHubCallbackPage.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { useGitHubRepos } from '@/hooks/useGitHubRepos'; + +export function GitHubCallbackPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { completeConnect } = useGitHubRepos(); + const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing'); + const [errorMessage, setErrorMessage] = useState(''); + + const callbackRanRef = useRef(false); + const timeoutRef = useRef(null); + + useEffect(() => { + // Prevent double-execution in React StrictMode + if (callbackRanRef.current) return; + + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + // Also check if we already processed this code (sessionStorage cleanup happens on success) + const storedState = sessionStorage.getItem('github_oauth_state'); + if (!storedState) { + // State already cleared = already processed + setStatus('success'); + timeoutRef.current = setTimeout(() => { + navigate('/dashboard?openGitHubImport=true', { replace: true }); + }, 500); + return; + } + + callbackRanRef.current = true; + + let mounted = true; + + const handleCallback = async () => { + const error = searchParams.get('error'); + const errorDescription = searchParams.get('error_description'); + + // Handle GitHub OAuth errors + if (error) { + if (mounted) { + setStatus('error'); + setErrorMessage(errorDescription || error || 'GitHub authorization failed'); + } + return; + } + + if (!code || !state) { + if (mounted) { + setStatus('error'); + setErrorMessage('Missing authorization code or state'); + } + return; + } + + // Exchange code for token via backend + try { + const success = await completeConnect(code, state); + + if (!mounted) return; + + if (success) { + setStatus('success'); + timeoutRef.current = setTimeout(() => { + if (mounted) { + // Navigate with param to auto-open import modal + navigate('/dashboard?openGitHubImport=true', { replace: true }); + } + }, 1500); + } else { + setStatus('error'); + setErrorMessage('Failed to connect GitHub account'); + } + } catch (err) { + if (!mounted) return; + setStatus('error'); + setErrorMessage(err instanceof Error ? err.message : 'An unexpected error occurred'); + } + }; + + handleCallback(); + + return () => { + mounted = false; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [searchParams, completeConnect, navigate]); + + return ( +
+
+ {status === 'processing' && ( + <> + +

Connecting GitHub...

+

Please wait while we complete the connection

+ + )} + + {status === 'success' && ( + <> + +

GitHub Connected!

+

Redirecting to dashboard...

+ + )} + + {status === 'error' && ( + <> + +

Connection Failed

+

{errorMessage}

+ + + )} +
+
+ ); +} diff --git a/supabase/migrations/003_github_connections.sql b/supabase/migrations/003_github_connections.sql new file mode 100644 index 0000000..3307fd1 --- /dev/null +++ b/supabase/migrations/003_github_connections.sql @@ -0,0 +1,71 @@ +-- GitHub Connections Table +-- Stores OAuth tokens for GitHub repo import feature +-- Token stored server-side only, never exposed to frontend + +CREATE TABLE IF NOT EXISTS github_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + github_user_id BIGINT NOT NULL, + github_username TEXT NOT NULL, + github_avatar_url TEXT, + access_token TEXT NOT NULL, + token_scope TEXT NOT NULL DEFAULT 'repo', + connected_at TIMESTAMPTZ DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id) +); + +-- Index for fast lookups +CREATE INDEX IF NOT EXISTS idx_github_connections_user_id ON github_connections(user_id); + +-- RLS policies +ALTER TABLE github_connections ENABLE ROW LEVEL SECURITY; + +-- No direct SELECT access for authenticated users on base table +-- This prevents access_token from being exposed +-- Backend uses service_role key which bypasses RLS + +-- Create a secure view that excludes sensitive columns +-- This is what frontend/API queries should use for status checks +CREATE OR REPLACE VIEW github_connections_public AS +SELECT + id, + user_id, + github_user_id, + github_username, + github_avatar_url, + token_scope, + connected_at, + last_used_at, + created_at, + updated_at +FROM github_connections; + +-- Grant SELECT on the view to authenticated users +-- View inherits RLS from base table, but we add explicit policy +GRANT SELECT ON github_connections_public TO authenticated; + +-- RLS policy for the view (checks user_id match) +CREATE POLICY "Users can view own connection via public view" ON github_connections + FOR SELECT USING (auth.uid() = user_id); + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_github_connections_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER github_connections_updated_at + BEFORE UPDATE ON github_connections + FOR EACH ROW + EXECUTE FUNCTION update_github_connections_updated_at(); + +-- Comments for documentation +COMMENT ON TABLE github_connections IS 'Stores GitHub OAuth tokens for repo import. Tokens are server-side only via service_role.'; +COMMENT ON COLUMN github_connections.access_token IS 'GitHub OAuth access token. Never exposed to frontend - use github_connections_public view.'; +COMMENT ON VIEW github_connections_public IS 'Safe view excluding access_token. Use this for frontend status checks.';