From 6dc1c98f139411a1fe8140bc6fd2b82c3f36f9ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 04:14:57 +0000 Subject: [PATCH] feat: Add GitHub Integration backend infrastructure Implemented complete backend infrastructure for GitHub Integration feature: Database Schema: - Created github_connections table for OAuth tokens and user GitHub info - Created github_repositories table for connected repositories with metadata - Created github_code_embeds table for living code references in documents - Created github_search_cache table for performance optimization - Added migration 0006_github_integration.sql with proper indexes and foreign keys GitHub Service (packages/lib/src/services/github-service.ts): - GitHubService class for all GitHub API interactions - OAuth token encryption/decryption utilities - Methods for repository listing, file browsing, and code search - GitHubOAuth class for OAuth flow management - Support for branches, commits, and file content retrieval - Language detection from file extensions API Routes (apps/web/src/app/api/github/): - /auth/connect - Initiate GitHub OAuth flow with CSRF protection - /auth/callback - Handle OAuth callback and store encrypted tokens - /connections - Manage GitHub connections (list, validate, delete) - /repositories - Connect/disconnect repos, list available/connected repos - /search - Search code across connected repositories with caching - /files - Browse repository files and create code embeds Features: - Complete OAuth 2.0 flow with state parameter for CSRF protection - Encrypted token storage using encryption-utils - Permission-based access control (drive ownership and membership) - Rate limiting on OAuth endpoints - Search result caching with 1-hour expiration - Support for line-range code snippets - Automatic language detection for syntax highlighting - Repository sync metadata tracking Security: - All tokens encrypted before database storage - Permission checks on all operations - Rate limiting on authentication endpoints - State validation with timestamp expiry (10 minutes) - Drive-level access control for all repository operations --- .../src/app/api/github/auth/callback/route.ts | 192 ++++++++ .../src/app/api/github/auth/connect/route.ts | 158 ++++++ .../src/app/api/github/connections/route.ts | 213 ++++++++ apps/web/src/app/api/github/files/route.ts | 455 +++++++++++++++++ .../src/app/api/github/repositories/route.ts | 441 +++++++++++++++++ apps/web/src/app/api/github/search/route.ts | 294 +++++++++++ .../db/drizzle/0006_github_integration.sql | 142 ++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 3 + packages/db/src/schema/github.ts | 200 ++++++++ packages/lib/src/services/github-service.ts | 457 ++++++++++++++++++ 11 files changed, 2562 insertions(+) create mode 100644 apps/web/src/app/api/github/auth/callback/route.ts create mode 100644 apps/web/src/app/api/github/auth/connect/route.ts create mode 100644 apps/web/src/app/api/github/connections/route.ts create mode 100644 apps/web/src/app/api/github/files/route.ts create mode 100644 apps/web/src/app/api/github/repositories/route.ts create mode 100644 apps/web/src/app/api/github/search/route.ts create mode 100644 packages/db/drizzle/0006_github_integration.sql create mode 100644 packages/db/src/schema/github.ts create mode 100644 packages/lib/src/services/github-service.ts diff --git a/apps/web/src/app/api/github/auth/callback/route.ts b/apps/web/src/app/api/github/auth/callback/route.ts new file mode 100644 index 000000000..c8498a733 --- /dev/null +++ b/apps/web/src/app/api/github/auth/callback/route.ts @@ -0,0 +1,192 @@ +/** + * GitHub OAuth Callback Handler + * GET /api/github/auth/callback + * + * Handles the OAuth callback from GitHub and stores the connection + */ + +import { githubConnections } from '@pagespace/db'; +import { db, eq } from '@pagespace/db'; +import { z } from 'zod/v4'; +import { checkRateLimit, RATE_LIMIT_CONFIGS } from '@pagespace/lib/server'; +import { loggers } from '@pagespace/lib/server'; +import { NextResponse } from 'next/server'; +import { GitHubService } from '@pagespace/lib/services/github-service'; + +const githubCallbackSchema = z.object({ + code: z.string().min(1, 'Authorization code is required'), + state: z.string().min(1, 'State parameter is required'), +}); + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + + const baseUrl = process.env.NEXTAUTH_URL || process.env.WEB_APP_URL || req.url; + + if (error) { + loggers.auth.warn('GitHub OAuth error', { error }); + + let errorParam = 'github_oauth_error'; + if (error === 'access_denied') { + errorParam = 'github_access_denied'; + } + + return NextResponse.redirect(new URL(`/settings?error=${errorParam}`, baseUrl)); + } + + const validation = githubCallbackSchema.safeParse({ code, state }); + if (!validation.success) { + loggers.auth.warn('Invalid GitHub OAuth callback parameters', validation.error); + return NextResponse.redirect(new URL('/settings?error=github_invalid_request', baseUrl)); + } + + const { code: authCode, state: encodedState } = validation.data; + + // Decode and validate state + let stateData: { + state: string; + userId: string; + driveId?: string; + returnUrl?: string; + timestamp: number; + }; + + try { + const decodedState = Buffer.from(encodedState, 'base64url').toString('utf-8'); + stateData = JSON.parse(decodedState); + + // Validate state timestamp (prevent replay attacks, 10 minutes expiry) + const stateAge = Date.now() - stateData.timestamp; + if (stateAge > 10 * 60 * 1000) { + loggers.auth.warn('Expired GitHub OAuth state', { age: stateAge }); + return NextResponse.redirect(new URL('/settings?error=github_state_expired', baseUrl)); + } + } catch (e) { + loggers.auth.error('Invalid GitHub OAuth state', e as Error); + return NextResponse.redirect(new URL('/settings?error=github_invalid_state', baseUrl)); + } + + // Rate limiting + const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || + req.headers.get('x-real-ip') || + 'unknown'; + + const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN); + if (!ipRateLimit.allowed) { + return NextResponse.redirect(new URL('/settings?error=github_rate_limit', baseUrl)); + } + + // Check environment variables + if (!process.env.GITHUB_OAUTH_CLIENT_ID || !process.env.GITHUB_OAUTH_CLIENT_SECRET || !process.env.GITHUB_OAUTH_REDIRECT_URI) { + loggers.auth.error('GitHub OAuth not configured'); + return NextResponse.redirect(new URL('/settings?error=github_not_configured', baseUrl)); + } + + // Exchange authorization code for access token + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: process.env.GITHUB_OAUTH_CLIENT_ID, + client_secret: process.env.GITHUB_OAUTH_CLIENT_SECRET, + code: authCode, + redirect_uri: process.env.GITHUB_OAUTH_REDIRECT_URI, + }), + }); + + if (!tokenResponse.ok) { + loggers.auth.error('Failed to exchange GitHub code for token', { + status: tokenResponse.status, + }); + return NextResponse.redirect(new URL('/settings?error=github_token_exchange_failed', baseUrl)); + } + + const tokenData = await tokenResponse.json(); + + if (tokenData.error) { + loggers.auth.error('GitHub OAuth token error', { error: tokenData.error }); + return NextResponse.redirect(new URL('/settings?error=github_token_error', baseUrl)); + } + + const { access_token, token_type, scope } = tokenData; + + if (!access_token) { + loggers.auth.error('No access token received from GitHub'); + return NextResponse.redirect(new URL('/settings?error=github_no_token', baseUrl)); + } + + // Get GitHub user information + const githubService = new GitHubService(access_token); + const githubUser = await githubService.getAuthenticatedUser(); + + if (!githubUser) { + loggers.auth.error('Failed to get GitHub user information'); + return NextResponse.redirect(new URL('/settings?error=github_user_info_failed', baseUrl)); + } + + // Check if connection already exists + const existingConnection = await db.query.githubConnections.findFirst({ + where: eq(githubConnections.userId, stateData.userId), + }); + + // Encrypt the access token before storing + const encryptedToken = GitHubService.encryptToken(access_token); + + if (existingConnection) { + // Update existing connection + await db.update(githubConnections) + .set({ + githubUserId: githubUser.id.toString(), + githubUsername: githubUser.login, + githubEmail: githubUser.email || null, + githubAvatarUrl: githubUser.avatar_url, + encryptedAccessToken: encryptedToken, + tokenType: token_type || 'Bearer', + scope: scope || null, + lastUsed: new Date(), + updatedAt: new Date(), + revokedAt: null, // Clear revoked status if reconnecting + }) + .where(eq(githubConnections.id, existingConnection.id)); + + loggers.auth.info('Updated GitHub connection', { + userId: stateData.userId, + githubUsername: githubUser.login, + }); + } else { + // Create new connection + await db.insert(githubConnections).values({ + userId: stateData.userId, + githubUserId: githubUser.id.toString(), + githubUsername: githubUser.login, + githubEmail: githubUser.email || null, + githubAvatarUrl: githubUser.avatar_url, + encryptedAccessToken: encryptedToken, + tokenType: token_type || 'Bearer', + scope: scope || null, + lastUsed: new Date(), + }); + + loggers.auth.info('Created new GitHub connection', { + userId: stateData.userId, + githubUsername: githubUser.login, + }); + } + + // Redirect to return URL or settings page + const redirectUrl = stateData.returnUrl || '/settings?tab=integrations&github=connected'; + return NextResponse.redirect(new URL(redirectUrl, baseUrl)); + + } catch (error) { + loggers.auth.error('GitHub OAuth callback error', error as Error); + const baseUrl = process.env.NEXTAUTH_URL || process.env.WEB_APP_URL || req.url; + return NextResponse.redirect(new URL('/settings?error=github_callback_error', baseUrl)); + } +} diff --git a/apps/web/src/app/api/github/auth/connect/route.ts b/apps/web/src/app/api/github/auth/connect/route.ts new file mode 100644 index 000000000..22bf007de --- /dev/null +++ b/apps/web/src/app/api/github/auth/connect/route.ts @@ -0,0 +1,158 @@ +/** + * GitHub OAuth Connection Initiation + * POST /api/github/auth/connect + * + * Initiates the GitHub OAuth flow for connecting a user's GitHub account + */ + +import { z } from 'zod/v4'; +import { checkRateLimit, RATE_LIMIT_CONFIGS } from '@pagespace/lib/server'; +import { loggers } from '@pagespace/lib/server'; +import { verify } from '@pagespace/lib/server'; +import { createId } from '@paralleldrive/cuid2'; + +const githubConnectSchema = z.object({ + driveId: z.string().optional(), + returnUrl: z.string().optional(), +}); + +export async function POST(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const validation = githubConnectSchema.safeParse(body); + + if (!validation.success) { + return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 }); + } + + // Rate limiting by IP address + const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || + req.headers.get('x-real-ip') || + 'unknown'; + + const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN); + if (!ipRateLimit.allowed) { + return Response.json( + { + error: 'Too many connection attempts. Please try again later.', + retryAfter: ipRateLimit.retryAfter + }, + { + status: 429, + headers: { + 'Retry-After': ipRateLimit.retryAfter?.toString() || '900' + } + } + ); + } + + const { driveId, returnUrl } = validation.data; + + // Check environment variables + if (!process.env.GITHUB_OAUTH_CLIENT_ID || !process.env.GITHUB_OAUTH_REDIRECT_URI) { + loggers.auth.error('GitHub OAuth not configured'); + return Response.json({ error: 'GitHub integration not configured' }, { status: 500 }); + } + + // Generate state parameter for CSRF protection + const state = createId(); + + // Store state in a way that can be verified in callback + // For now, we'll encode the userId, driveId, and returnUrl in the state + const stateData = { + state, + userId: payload.userId, + driveId, + returnUrl, + timestamp: Date.now(), + }; + const encodedState = Buffer.from(JSON.stringify(stateData)).toString('base64url'); + + // Generate OAuth URL + const scopes = ['repo', 'read:user', 'user:email']; + const params = new URLSearchParams({ + client_id: process.env.GITHUB_OAUTH_CLIENT_ID, + redirect_uri: process.env.GITHUB_OAUTH_REDIRECT_URI, + scope: scopes.join(' '), + state: encodedState, + allow_signup: 'true', + }); + + const oauthUrl = `https://github.com/login/oauth/authorize?${params.toString()}`; + + loggers.auth.info('GitHub OAuth connection initiated', { + userId: payload.userId, + driveId, + }); + + return Response.json({ url: oauthUrl, state }); + + } catch (error) { + loggers.auth.error('GitHub OAuth connect error', error as Error); + return Response.json({ error: 'An unexpected error occurred.' }, { status: 500 }); + } +} + +export async function GET(req: Request) { + try { + // Verify authentication + const { searchParams } = new URL(req.url); + const token = searchParams.get('token'); + + if (!token) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + // Check environment variables + if (!process.env.GITHUB_OAUTH_CLIENT_ID || !process.env.GITHUB_OAUTH_REDIRECT_URI) { + loggers.auth.error('GitHub OAuth not configured'); + const baseUrl = process.env.WEB_APP_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000'; + return Response.redirect(new URL('/settings?error=github_not_configured', baseUrl).toString()); + } + + // Generate state parameter + const state = createId(); + const stateData = { + state, + userId: payload.userId, + timestamp: Date.now(), + }; + const encodedState = Buffer.from(JSON.stringify(stateData)).toString('base64url'); + + // Generate OAuth URL for direct link access + const scopes = ['repo', 'read:user', 'user:email']; + const params = new URLSearchParams({ + client_id: process.env.GITHUB_OAUTH_CLIENT_ID, + redirect_uri: process.env.GITHUB_OAUTH_REDIRECT_URI, + scope: scopes.join(' '), + state: encodedState, + allow_signup: 'true', + }); + + const oauthUrl = `https://github.com/login/oauth/authorize?${params.toString()}`; + + return Response.redirect(oauthUrl); + + } catch (error) { + loggers.auth.error('GitHub OAuth connect GET error', error as Error); + const baseUrl = process.env.WEB_APP_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000'; + return Response.redirect(new URL('/settings?error=github_error', baseUrl).toString()); + } +} diff --git a/apps/web/src/app/api/github/connections/route.ts b/apps/web/src/app/api/github/connections/route.ts new file mode 100644 index 000000000..504db4b11 --- /dev/null +++ b/apps/web/src/app/api/github/connections/route.ts @@ -0,0 +1,213 @@ +/** + * GitHub Connections Management + * GET /api/github/connections - List user's GitHub connections + * DELETE /api/github/connections - Remove GitHub connection + */ + +import { githubConnections, githubRepositories } from '@pagespace/db'; +import { db, eq } from '@pagespace/db'; +import { verify } from '@pagespace/lib/server'; +import { loggers } from '@pagespace/lib/server'; +import { z } from 'zod/v4'; +import { GitHubService } from '@pagespace/lib/services/github-service'; + +/** + * GET - List GitHub connections for authenticated user + */ +export async function GET(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + // Get user's GitHub connections + const connections = await db.query.githubConnections.findMany({ + where: eq(githubConnections.userId, payload.userId), + with: { + repositories: { + columns: { + id: true, + fullName: true, + enabled: true, + }, + }, + }, + }); + + // Don't return encrypted tokens in the response + const sanitizedConnections = connections.map((conn) => ({ + id: conn.id, + githubUserId: conn.githubUserId, + githubUsername: conn.githubUsername, + githubEmail: conn.githubEmail, + githubAvatarUrl: conn.githubAvatarUrl, + tokenType: conn.tokenType, + scope: conn.scope, + lastUsed: conn.lastUsed, + createdAt: conn.createdAt, + updatedAt: conn.updatedAt, + revokedAt: conn.revokedAt, + repositories: conn.repositories, + })); + + return Response.json(sanitizedConnections); + + } catch (error) { + loggers.auth.error('Failed to list GitHub connections', error as Error); + return Response.json({ error: 'Failed to retrieve connections' }, { status: 500 }); + } +} + +const deleteConnectionSchema = z.object({ + connectionId: z.string().optional(), +}); + +/** + * DELETE - Remove GitHub connection + */ +export async function DELETE(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const validation = deleteConnectionSchema.safeParse(body); + + if (!validation.success) { + return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 }); + } + + const { connectionId } = validation.data; + + // If connectionId is provided, delete that specific connection + // Otherwise, delete all connections for the user + if (connectionId) { + // Verify the connection belongs to the user + const connection = await db.query.githubConnections.findFirst({ + where: eq(githubConnections.id, connectionId), + }); + + if (!connection) { + return Response.json({ error: 'Connection not found' }, { status: 404 }); + } + + if (connection.userId !== payload.userId) { + return Response.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Delete the connection (cascade will delete related repositories and embeds) + await db.delete(githubConnections).where(eq(githubConnections.id, connectionId)); + + loggers.auth.info('Deleted GitHub connection', { + userId: payload.userId, + connectionId, + githubUsername: connection.githubUsername, + }); + } else { + // Delete all connections for the user + await db.delete(githubConnections).where(eq(githubConnections.userId, payload.userId)); + + loggers.auth.info('Deleted all GitHub connections', { + userId: payload.userId, + }); + } + + return Response.json({ success: true }); + + } catch (error) { + loggers.auth.error('Failed to delete GitHub connection', error as Error); + return Response.json({ error: 'Failed to delete connection' }, { status: 500 }); + } +} + +/** + * PATCH - Validate and refresh GitHub connection + */ +export async function PATCH(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const connectionId = searchParams.get('connectionId'); + + if (!connectionId) { + return Response.json({ error: 'Connection ID required' }, { status: 400 }); + } + + // Get the connection + const connection = await db.query.githubConnections.findFirst({ + where: eq(githubConnections.id, connectionId), + }); + + if (!connection) { + return Response.json({ error: 'Connection not found' }, { status: 404 }); + } + + if (connection.userId !== payload.userId) { + return Response.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Validate the token + const githubService = GitHubService.fromEncryptedToken(connection.encryptedAccessToken); + const isValid = await githubService.validateToken(); + + if (!isValid) { + // Mark connection as invalid + await db.update(githubConnections) + .set({ + revokedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(githubConnections.id, connectionId)); + + return Response.json({ + valid: false, + message: 'GitHub token is invalid or expired. Please reconnect.', + }); + } + + // Update last used timestamp + await db.update(githubConnections) + .set({ + lastUsed: new Date(), + updatedAt: new Date(), + }) + .where(eq(githubConnections.id, connectionId)); + + return Response.json({ + valid: true, + message: 'GitHub connection is valid', + }); + + } catch (error) { + loggers.auth.error('Failed to validate GitHub connection', error as Error); + return Response.json({ error: 'Failed to validate connection' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/github/files/route.ts b/apps/web/src/app/api/github/files/route.ts new file mode 100644 index 000000000..c94254a85 --- /dev/null +++ b/apps/web/src/app/api/github/files/route.ts @@ -0,0 +1,455 @@ +/** + * GitHub File Browsing + * GET /api/github/files - Browse repository files and get file contents + */ + +import { githubRepositories, githubCodeEmbeds, driveMembers } from '@pagespace/db'; +import { db, eq, and } from '@pagespace/db'; +import { verify } from '@pagespace/lib/server'; +import { loggers } from '@pagespace/lib/server'; +import { GitHubService, detectLanguageFromPath } from '@pagespace/lib/services/github-service'; +import { createId } from '@paralleldrive/cuid2'; +import { z } from 'zod/v4'; + +/** + * GET - Browse repository files or get file content + * Query params: + * - repositoryId: Repository ID (required) + * - path: File or directory path (default: root) + * - ref: Branch or commit SHA (default: repository default branch) + * - startLine: Start line for code snippet (optional) + * - endLine: End line for code snippet (optional) + */ +export async function GET(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const repositoryId = searchParams.get('repositoryId'); + const path = searchParams.get('path') || ''; + const ref = searchParams.get('ref'); + const startLine = searchParams.get('startLine'); + const endLine = searchParams.get('endLine'); + + if (!repositoryId) { + return Response.json({ error: 'Repository ID required' }, { status: 400 }); + } + + // Get the repository + const repo = await db.query.githubRepositories.findFirst({ + where: eq(githubRepositories.id, repositoryId), + with: { + drive: true, + connection: true, + }, + }); + + if (!repo) { + return Response.json({ error: 'Repository not found' }, { status: 404 }); + } + + // Verify access to the drive + if (repo.drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, repo.driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + } + + // Create GitHub service + const githubService = GitHubService.fromEncryptedToken(repo.connection.encryptedAccessToken); + + // Determine the branch to use + const branch = ref || repo.defaultBranch; + + // Get file/directory contents + const contents = await githubService.getContents(repo.owner, repo.name, path, branch); + + // If it's a directory, return the listing + if (Array.isArray(contents)) { + return Response.json({ + type: 'directory', + path, + branch, + items: contents, + }); + } + + // If it's a file, get the content + if (contents.type === 'file') { + const fileContent = await githubService.getFileContent(repo.owner, repo.name, path, branch); + + // Extract line range if specified + let finalContent = fileContent.content; + if (startLine && endLine) { + const lines = fileContent.content.split('\n'); + const start = parseInt(startLine) - 1; // Convert to 0-indexed + const end = parseInt(endLine); + finalContent = lines.slice(start, end).join('\n'); + } + + // Detect language + const language = detectLanguageFromPath(path); + + return Response.json({ + type: 'file', + path, + branch, + content: finalContent, + sha: fileContent.sha, + size: fileContent.size, + language, + startLine: startLine ? parseInt(startLine) : undefined, + endLine: endLine ? parseInt(endLine) : undefined, + }); + } + + return Response.json({ + type: contents.type, + path, + branch, + message: `Unsupported type: ${contents.type}`, + }); + + } catch (error) { + loggers.auth.error('Failed to browse GitHub files', error as Error); + return Response.json({ error: 'Failed to browse files' }, { status: 500 }); + } +} + +const createEmbedSchema = z.object({ + repositoryId: z.string(), + filePath: z.string(), + branch: z.string(), + startLine: z.number().optional(), + endLine: z.number().optional(), + showLineNumbers: z.boolean().default(true), + highlightLines: z.array(z.number()).optional(), +}); + +/** + * POST - Create a code embed + */ +export async function POST(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const validation = createEmbedSchema.safeParse(body); + + if (!validation.success) { + return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 }); + } + + const { repositoryId, filePath, branch, startLine, endLine, showLineNumbers, highlightLines } = validation.data; + + // Get the repository + const repo = await db.query.githubRepositories.findFirst({ + where: eq(githubRepositories.id, repositoryId), + with: { + drive: true, + connection: true, + }, + }); + + if (!repo) { + return Response.json({ error: 'Repository not found' }, { status: 404 }); + } + + // Verify access + if (repo.drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, repo.driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + } + + // Fetch the file content + const githubService = GitHubService.fromEncryptedToken(repo.connection.encryptedAccessToken); + const fileContent = await githubService.getFileContent(repo.owner, repo.name, filePath, branch); + + // Get the latest commit SHA for this file + const commits = await githubService.listCommits(repo.owner, repo.name, { + path: filePath, + sha: branch, + per_page: 1, + }); + const commitSha = commits[0]?.sha || null; + + // Extract line range if specified + let content = fileContent.content; + if (startLine && endLine) { + const lines = fileContent.content.split('\n'); + const start = startLine - 1; // Convert to 0-indexed + const end = endLine; + content = lines.slice(start, end).join('\n'); + } + + // Detect language + const language = detectLanguageFromPath(filePath); + + // Create the embed record + const [embed] = await db.insert(githubCodeEmbeds).values({ + id: createId(), + repositoryId, + filePath, + branch, + startLine: startLine || null, + endLine: endLine || null, + content, + language, + fileSize: fileContent.size, + commitSha, + lastFetchedAt: new Date(), + showLineNumbers, + highlightLines: highlightLines || null, + }).returning(); + + loggers.auth.info('Created GitHub code embed', { + userId: payload.userId, + embedId: embed.id, + repository: repo.fullName, + filePath, + }); + + return Response.json(embed, { status: 201 }); + + } catch (error) { + loggers.auth.error('Failed to create code embed', error as Error); + return Response.json({ error: 'Failed to create code embed' }, { status: 500 }); + } +} + +const updateEmbedSchema = z.object({ + embedId: z.string(), + showLineNumbers: z.boolean().optional(), + highlightLines: z.array(z.number()).optional(), + refresh: z.boolean().optional(), +}); + +/** + * PATCH - Update or refresh a code embed + */ +export async function PATCH(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const validation = updateEmbedSchema.safeParse(body); + + if (!validation.success) { + return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 }); + } + + const { embedId, showLineNumbers, highlightLines, refresh } = validation.data; + + // Get the embed with repository info + const embed = await db.query.githubCodeEmbeds.findFirst({ + where: eq(githubCodeEmbeds.id, embedId), + with: { + repository: { + with: { + drive: true, + connection: true, + }, + }, + }, + }); + + if (!embed) { + return Response.json({ error: 'Code embed not found' }, { status: 404 }); + } + + // Verify access + if (embed.repository.drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, embed.repository.driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + } + + const updates: Partial = { + updatedAt: new Date(), + }; + + if (showLineNumbers !== undefined) { + updates.showLineNumbers = showLineNumbers; + } + + if (highlightLines !== undefined) { + updates.highlightLines = highlightLines; + } + + // Refresh content if requested + if (refresh) { + try { + const githubService = GitHubService.fromEncryptedToken( + embed.repository.connection.encryptedAccessToken + ); + + const fileContent = await githubService.getFileContent( + embed.repository.owner, + embed.repository.name, + embed.filePath, + embed.branch + ); + + // Extract line range + let content = fileContent.content; + if (embed.startLine && embed.endLine) { + const lines = fileContent.content.split('\n'); + const start = embed.startLine - 1; + const end = embed.endLine; + content = lines.slice(start, end).join('\n'); + } + + // Get latest commit + const commits = await githubService.listCommits( + embed.repository.owner, + embed.repository.name, + { + path: embed.filePath, + sha: embed.branch, + per_page: 1, + } + ); + + updates.content = content; + updates.commitSha = commits[0]?.sha || null; + updates.fileSize = fileContent.size; + updates.lastFetchedAt = new Date(); + updates.fetchError = null; + + } catch (error) { + updates.fetchError = (error as Error).message; + loggers.auth.error('Failed to refresh code embed', { + error: error as Error, + embedId, + }); + } + } + + const [updatedEmbed] = await db.update(githubCodeEmbeds) + .set(updates) + .where(eq(githubCodeEmbeds.id, embedId)) + .returning(); + + return Response.json(updatedEmbed); + + } catch (error) { + loggers.auth.error('Failed to update code embed', error as Error); + return Response.json({ error: 'Failed to update code embed' }, { status: 500 }); + } +} + +/** + * DELETE - Delete a code embed + */ +export async function DELETE(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const embedId = searchParams.get('embedId'); + + if (!embedId) { + return Response.json({ error: 'Embed ID required' }, { status: 400 }); + } + + // Get the embed + const embed = await db.query.githubCodeEmbeds.findFirst({ + where: eq(githubCodeEmbeds.id, embedId), + with: { + repository: { + with: { + drive: true, + }, + }, + }, + }); + + if (!embed) { + return Response.json({ error: 'Code embed not found' }, { status: 404 }); + } + + // Verify access + if (embed.repository.drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, embed.repository.driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + } + + await db.delete(githubCodeEmbeds).where(eq(githubCodeEmbeds.id, embedId)); + + return Response.json({ success: true }); + + } catch (error) { + loggers.auth.error('Failed to delete code embed', error as Error); + return Response.json({ error: 'Failed to delete code embed' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/github/repositories/route.ts b/apps/web/src/app/api/github/repositories/route.ts new file mode 100644 index 000000000..fbc617b49 --- /dev/null +++ b/apps/web/src/app/api/github/repositories/route.ts @@ -0,0 +1,441 @@ +/** + * GitHub Repositories Management + * GET /api/github/repositories - List available or connected repositories + * POST /api/github/repositories - Connect a repository to a drive + * DELETE /api/github/repositories - Disconnect a repository + * PATCH /api/github/repositories - Update repository settings + */ + +import { githubConnections, githubRepositories, drives, driveMembers } from '@pagespace/db'; +import { db, eq, and, inArray } from '@pagespace/db'; +import { verify } from '@pagespace/lib/server'; +import { loggers } from '@pagespace/lib/server'; +import { z } from 'zod/v4'; +import { GitHubService } from '@pagespace/lib/services/github-service'; +import { createId } from '@paralleldrive/cuid2'; + +/** + * GET - List GitHub repositories + * Query params: + * - driveId: Filter by drive (returns connected repos) + * - available: true to list available repos from GitHub (requires connectionId) + * - connectionId: GitHub connection to use + */ +export async function GET(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const driveId = searchParams.get('driveId'); + const available = searchParams.get('available') === 'true'; + const connectionId = searchParams.get('connectionId'); + + if (available) { + // List available repositories from GitHub + if (!connectionId) { + return Response.json({ error: 'Connection ID required for available repositories' }, { status: 400 }); + } + + // Get the connection + const connection = await db.query.githubConnections.findFirst({ + where: and( + eq(githubConnections.id, connectionId), + eq(githubConnections.userId, payload.userId) + ), + }); + + if (!connection) { + return Response.json({ error: 'GitHub connection not found' }, { status: 404 }); + } + + // Fetch repositories from GitHub + const githubService = GitHubService.fromEncryptedToken(connection.encryptedAccessToken); + const repos = await githubService.listRepositories({ + per_page: 100, + sort: 'updated', + }); + + return Response.json(repos); + + } else if (driveId) { + // List connected repositories for a specific drive + // Verify user has access to the drive + const drive = await db.query.drives.findFirst({ + where: eq(drives.id, driveId), + }); + + if (!drive) { + return Response.json({ error: 'Drive not found' }, { status: 404 }); + } + + // Check if user is owner or member + if (drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + } + + // Get connected repositories + const repos = await db.query.githubRepositories.findMany({ + where: eq(githubRepositories.driveId, driveId), + with: { + connection: { + columns: { + githubUsername: true, + githubAvatarUrl: true, + }, + }, + }, + }); + + return Response.json(repos); + + } else { + // List all connected repositories for the user across all their drives + // Get all drives the user owns or is a member of + const ownedDrives = await db.query.drives.findMany({ + where: eq(drives.ownerId, payload.userId), + columns: { id: true }, + }); + + const memberDrives = await db.query.driveMembers.findMany({ + where: eq(driveMembers.userId, payload.userId), + columns: { driveId: true }, + }); + + const driveIds = [ + ...ownedDrives.map((d) => d.id), + ...memberDrives.map((m) => m.driveId), + ]; + + if (driveIds.length === 0) { + return Response.json([]); + } + + const repos = await db.query.githubRepositories.findMany({ + where: inArray(githubRepositories.driveId, driveIds), + with: { + drive: { + columns: { + id: true, + name: true, + }, + }, + connection: { + columns: { + githubUsername: true, + githubAvatarUrl: true, + }, + }, + }, + }); + + return Response.json(repos); + } + + } catch (error) { + loggers.auth.error('Failed to list GitHub repositories', error as Error); + return Response.json({ error: 'Failed to retrieve repositories' }, { status: 500 }); + } +} + +const connectRepoSchema = z.object({ + driveId: z.string(), + connectionId: z.string(), + owner: z.string(), + name: z.string(), + branches: z.array(z.string()).optional(), +}); + +/** + * POST - Connect a GitHub repository to a drive + */ +export async function POST(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const validation = connectRepoSchema.safeParse(body); + + if (!validation.success) { + return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 }); + } + + const { driveId, connectionId, owner, name, branches } = validation.data; + + // Verify user owns the drive or is an admin + const drive = await db.query.drives.findFirst({ + where: eq(drives.id, driveId), + }); + + if (!drive) { + return Response.json({ error: 'Drive not found' }, { status: 404 }); + } + + if (drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership || membership.role === 'MEMBER') { + return Response.json({ error: 'Admin access required' }, { status: 403 }); + } + } + + // Verify the connection belongs to the user + const connection = await db.query.githubConnections.findFirst({ + where: and( + eq(githubConnections.id, connectionId), + eq(githubConnections.userId, payload.userId) + ), + }); + + if (!connection) { + return Response.json({ error: 'GitHub connection not found' }, { status: 404 }); + } + + // Fetch repository details from GitHub + const githubService = GitHubService.fromEncryptedToken(connection.encryptedAccessToken); + const repoData = await githubService.getRepository(owner, name); + + // Check if repository is already connected to this drive + const existing = await db.query.githubRepositories.findFirst({ + where: and( + eq(githubRepositories.driveId, driveId), + eq(githubRepositories.fullName, repoData.full_name) + ), + }); + + if (existing) { + return Response.json({ error: 'Repository already connected to this drive' }, { status: 409 }); + } + + // Create repository record + const [repo] = await db.insert(githubRepositories).values({ + id: createId(), + driveId, + connectionId, + githubRepoId: repoData.id, + owner: repoData.owner.login, + name: repoData.name, + fullName: repoData.full_name, + description: repoData.description, + isPrivate: repoData.private, + defaultBranch: repoData.default_branch, + language: repoData.language, + htmlUrl: repoData.html_url, + cloneUrl: repoData.clone_url, + stargazersCount: repoData.stargazers_count, + forksCount: repoData.forks_count, + openIssuesCount: repoData.open_issues_count, + lastSyncedAt: new Date(), + enabled: true, + branches: branches || null, + }).returning(); + + loggers.auth.info('Connected GitHub repository', { + userId: payload.userId, + driveId, + repoFullName: repoData.full_name, + }); + + return Response.json(repo, { status: 201 }); + + } catch (error) { + loggers.auth.error('Failed to connect GitHub repository', error as Error); + return Response.json({ error: 'Failed to connect repository' }, { status: 500 }); + } +} + +const disconnectRepoSchema = z.object({ + repositoryId: z.string(), +}); + +/** + * DELETE - Disconnect a GitHub repository + */ +export async function DELETE(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const validation = disconnectRepoSchema.safeParse(body); + + if (!validation.success) { + return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 }); + } + + const { repositoryId } = validation.data; + + // Get the repository + const repo = await db.query.githubRepositories.findFirst({ + where: eq(githubRepositories.id, repositoryId), + with: { + drive: true, + }, + }); + + if (!repo) { + return Response.json({ error: 'Repository not found' }, { status: 404 }); + } + + // Verify user owns the drive or is an admin + if (repo.drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, repo.driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership || membership.role === 'MEMBER') { + return Response.json({ error: 'Admin access required' }, { status: 403 }); + } + } + + // Delete the repository (cascade will delete related embeds) + await db.delete(githubRepositories).where(eq(githubRepositories.id, repositoryId)); + + loggers.auth.info('Disconnected GitHub repository', { + userId: payload.userId, + repositoryId, + repoFullName: repo.fullName, + }); + + return Response.json({ success: true }); + + } catch (error) { + loggers.auth.error('Failed to disconnect GitHub repository', error as Error); + return Response.json({ error: 'Failed to disconnect repository' }, { status: 500 }); + } +} + +const updateRepoSchema = z.object({ + repositoryId: z.string(), + enabled: z.boolean().optional(), + branches: z.array(z.string()).optional(), +}); + +/** + * PATCH - Update repository settings + */ +export async function PATCH(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const body = await req.json(); + const validation = updateRepoSchema.safeParse(body); + + if (!validation.success) { + return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 }); + } + + const { repositoryId, enabled, branches } = validation.data; + + // Get the repository + const repo = await db.query.githubRepositories.findFirst({ + where: eq(githubRepositories.id, repositoryId), + with: { + drive: true, + }, + }); + + if (!repo) { + return Response.json({ error: 'Repository not found' }, { status: 404 }); + } + + // Verify user owns the drive or is an admin + if (repo.drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, repo.driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership || membership.role === 'MEMBER') { + return Response.json({ error: 'Admin access required' }, { status: 403 }); + } + } + + // Update the repository + const updates: Partial = { + updatedAt: new Date(), + }; + + if (enabled !== undefined) { + updates.enabled = enabled; + } + + if (branches !== undefined) { + updates.branches = branches; + } + + const [updatedRepo] = await db.update(githubRepositories) + .set(updates) + .where(eq(githubRepositories.id, repositoryId)) + .returning(); + + loggers.auth.info('Updated GitHub repository settings', { + userId: payload.userId, + repositoryId, + updates, + }); + + return Response.json(updatedRepo); + + } catch (error) { + loggers.auth.error('Failed to update GitHub repository', error as Error); + return Response.json({ error: 'Failed to update repository' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/github/search/route.ts b/apps/web/src/app/api/github/search/route.ts new file mode 100644 index 000000000..3d7e6ff2c --- /dev/null +++ b/apps/web/src/app/api/github/search/route.ts @@ -0,0 +1,294 @@ +/** + * GitHub Code Search + * GET /api/github/search - Search code across connected repositories + */ + +import { githubConnections, githubRepositories, githubSearchCache, drives, driveMembers } from '@pagespace/db'; +import { db, eq, and, inArray, lt } from '@pagespace/db'; +import { verify } from '@pagespace/lib/server'; +import { loggers } from '@pagespace/lib/server'; +import { GitHubService } from '@pagespace/lib/services/github-service'; +import { createId } from '@paralleldrive/cuid2'; + +/** + * GET - Search code across GitHub repositories + * Query params: + * - q: Search query + * - driveId: Limit search to repositories in this drive + * - repositoryId: Limit search to specific repository + * - language: Filter by programming language + * - path: Filter by file path + * - per_page: Results per page (default 30) + * - page: Page number (default 1) + */ +export async function GET(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const query = searchParams.get('q'); + const driveId = searchParams.get('driveId'); + const repositoryId = searchParams.get('repositoryId'); + const language = searchParams.get('language'); + const path = searchParams.get('path'); + const per_page = parseInt(searchParams.get('per_page') || '30'); + const page = parseInt(searchParams.get('page') || '1'); + + if (!query) { + return Response.json({ error: 'Search query required' }, { status: 400 }); + } + + // Determine which repositories to search + let repositories; + + if (repositoryId) { + // Search specific repository + const repo = await db.query.githubRepositories.findFirst({ + where: eq(githubRepositories.id, repositoryId), + with: { + drive: true, + connection: true, + }, + }); + + if (!repo) { + return Response.json({ error: 'Repository not found' }, { status: 404 }); + } + + // Verify access to the drive + if (repo.drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, repo.driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + } + + repositories = [repo]; + + } else if (driveId) { + // Search all repositories in a drive + const drive = await db.query.drives.findFirst({ + where: eq(drives.id, driveId), + }); + + if (!drive) { + return Response.json({ error: 'Drive not found' }, { status: 404 }); + } + + // Verify access + if (drive.ownerId !== payload.userId) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, driveId), + eq(driveMembers.userId, payload.userId) + ), + }); + + if (!membership) { + return Response.json({ error: 'Access denied' }, { status: 403 }); + } + } + + repositories = await db.query.githubRepositories.findMany({ + where: and( + eq(githubRepositories.driveId, driveId), + eq(githubRepositories.enabled, true) + ), + with: { + connection: true, + }, + }); + + } else { + // Search all accessible repositories across all drives + const ownedDrives = await db.query.drives.findMany({ + where: eq(drives.ownerId, payload.userId), + columns: { id: true }, + }); + + const memberDrives = await db.query.driveMembers.findMany({ + where: eq(driveMembers.userId, payload.userId), + columns: { driveId: true }, + }); + + const driveIds = [ + ...ownedDrives.map((d) => d.id), + ...memberDrives.map((m) => m.driveId), + ]; + + if (driveIds.length === 0) { + return Response.json({ items: [], total_count: 0 }); + } + + repositories = await db.query.githubRepositories.findMany({ + where: and( + inArray(githubRepositories.driveId, driveIds), + eq(githubRepositories.enabled, true) + ), + with: { + connection: true, + }, + }); + } + + if (repositories.length === 0) { + return Response.json({ items: [], total_count: 0 }); + } + + // Check cache first + const cacheKey = `${query}:${repositories.map(r => r.id).join(',')}:${language || ''}:${path || ''}`; + const cached = await db.query.githubSearchCache.findFirst({ + where: and( + eq(githubSearchCache.query, cacheKey), + lt(githubSearchCache.expiresAt, new Date()) + ), + }); + + if (cached && cached.results) { + loggers.auth.info('GitHub search cache hit', { + userId: payload.userId, + query, + repositoryCount: repositories.length, + }); + + return Response.json({ + items: cached.results, + total_count: cached.resultCount, + cached: true, + }); + } + + // Perform search across repositories + // Group repositories by connection to minimize API calls + const connectionGroups = repositories.reduce((acc, repo) => { + const connId = repo.connectionId; + if (!acc[connId]) { + acc[connId] = []; + } + acc[connId].push(repo); + return {}; + }, {} as Record); + + const allResults: any[] = []; + + // Search each repository + for (const repo of repositories) { + try { + const githubService = GitHubService.fromEncryptedToken(repo.connection.encryptedAccessToken); + + const searchResults = await githubService.searchCode(query, { + repo: repo.fullName, + language: language || undefined, + path: path || undefined, + per_page, + page, + }); + + // Add repository context to results + const resultsWithContext = searchResults.items.map((item: any) => ({ + ...item, + repository: { + id: repo.id, + fullName: repo.fullName, + htmlUrl: repo.htmlUrl, + owner: repo.owner, + name: repo.name, + }, + })); + + allResults.push(...resultsWithContext); + + } catch (error) { + loggers.auth.error('GitHub search error for repository', { + error: error as Error, + repository: repo.fullName, + }); + // Continue searching other repositories + } + } + + // Sort results by score + allResults.sort((a, b) => (b.score || 0) - (a.score || 0)); + + // Cache the results (expire after 1 hour) + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + await db.insert(githubSearchCache).values({ + id: createId(), + driveId: driveId || repositories[0].driveId, + query: cacheKey, + repositoryIds: repositories.map(r => r.id), + results: allResults, + resultCount: allResults.length, + expiresAt, + }); + + loggers.auth.info('GitHub search completed', { + userId: payload.userId, + query, + repositoryCount: repositories.length, + resultCount: allResults.length, + }); + + return Response.json({ + items: allResults, + total_count: allResults.length, + cached: false, + }); + + } catch (error) { + loggers.auth.error('Failed to search GitHub code', error as Error); + return Response.json({ error: 'Failed to search code' }, { status: 500 }); + } +} + +/** + * DELETE - Clear search cache + */ +export async function DELETE(req: Request) { + try { + // Verify authentication + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + const payload = await verify(token); + if (!payload) { + return Response.json({ error: 'Invalid token' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const driveId = searchParams.get('driveId'); + + if (driveId) { + // Clear cache for specific drive + await db.delete(githubSearchCache).where(eq(githubSearchCache.driveId, driveId)); + } else { + // Clear all expired cache entries + await db.delete(githubSearchCache).where(lt(githubSearchCache.expiresAt, new Date())); + } + + return Response.json({ success: true }); + + } catch (error) { + loggers.auth.error('Failed to clear GitHub search cache', error as Error); + return Response.json({ error: 'Failed to clear cache' }, { status: 500 }); + } +} diff --git a/packages/db/drizzle/0006_github_integration.sql b/packages/db/drizzle/0006_github_integration.sql new file mode 100644 index 000000000..b3078a0c3 --- /dev/null +++ b/packages/db/drizzle/0006_github_integration.sql @@ -0,0 +1,142 @@ +-- GitHub Integration Tables Migration + +-- Create github_connections table +CREATE TABLE IF NOT EXISTS "github_connections" ( + "id" text PRIMARY KEY NOT NULL, + "userId" text NOT NULL, + "githubUserId" text NOT NULL, + "githubUsername" text NOT NULL, + "githubEmail" text, + "githubAvatarUrl" text, + "encryptedAccessToken" text NOT NULL, + "tokenType" text DEFAULT 'Bearer', + "scope" text, + "lastUsed" timestamp, + "expiresAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "revokedAt" timestamp +); +--> statement-breakpoint + +-- Create github_repositories table +CREATE TABLE IF NOT EXISTS "github_repositories" ( + "id" text PRIMARY KEY NOT NULL, + "driveId" text NOT NULL, + "connectionId" text NOT NULL, + "githubRepoId" integer NOT NULL, + "owner" text NOT NULL, + "name" text NOT NULL, + "fullName" text NOT NULL, + "description" text, + "isPrivate" boolean DEFAULT false NOT NULL, + "defaultBranch" text DEFAULT 'main' NOT NULL, + "language" text, + "htmlUrl" text NOT NULL, + "cloneUrl" text NOT NULL, + "stargazersCount" integer DEFAULT 0, + "forksCount" integer DEFAULT 0, + "openIssuesCount" integer DEFAULT 0, + "lastSyncedAt" timestamp, + "syncError" text, + "enabled" boolean DEFAULT true NOT NULL, + "branches" jsonb, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint + +-- Create github_code_embeds table +CREATE TABLE IF NOT EXISTS "github_code_embeds" ( + "id" text PRIMARY KEY NOT NULL, + "repositoryId" text NOT NULL, + "filePath" text NOT NULL, + "branch" text NOT NULL, + "startLine" integer, + "endLine" integer, + "content" text, + "language" text, + "fileSize" integer, + "commitSha" text, + "lastFetchedAt" timestamp, + "fetchError" text, + "showLineNumbers" boolean DEFAULT true NOT NULL, + "highlightLines" jsonb, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint + +-- Create github_search_cache table +CREATE TABLE IF NOT EXISTS "github_search_cache" ( + "id" text PRIMARY KEY NOT NULL, + "driveId" text NOT NULL, + "query" text NOT NULL, + "repositoryIds" jsonb, + "results" jsonb, + "resultCount" integer DEFAULT 0, + "expiresAt" timestamp NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint + +-- Add foreign key constraints +DO $$ BEGIN + ALTER TABLE "github_connections" ADD CONSTRAINT "github_connections_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint + +DO $$ BEGIN + ALTER TABLE "github_repositories" ADD CONSTRAINT "github_repositories_driveId_drives_id_fk" FOREIGN KEY ("driveId") REFERENCES "public"."drives"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint + +DO $$ BEGIN + ALTER TABLE "github_repositories" ADD CONSTRAINT "github_repositories_connectionId_github_connections_id_fk" FOREIGN KEY ("connectionId") REFERENCES "public"."github_connections"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint + +DO $$ BEGIN + ALTER TABLE "github_code_embeds" ADD CONSTRAINT "github_code_embeds_repositoryId_github_repositories_id_fk" FOREIGN KEY ("repositoryId") REFERENCES "public"."github_repositories"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint + +DO $$ BEGIN + ALTER TABLE "github_search_cache" ADD CONSTRAINT "github_search_cache_driveId_drives_id_fk" FOREIGN KEY ("driveId") REFERENCES "public"."drives"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint + +-- Create indexes +CREATE INDEX IF NOT EXISTS "github_connections_user_id_idx" ON "github_connections" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_connections_github_user_id_idx" ON "github_connections" USING btree ("githubUserId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_repositories_drive_id_idx" ON "github_repositories" USING btree ("driveId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_repositories_connection_id_idx" ON "github_repositories" USING btree ("connectionId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_repositories_github_repo_id_idx" ON "github_repositories" USING btree ("githubRepoId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_repositories_full_name_idx" ON "github_repositories" USING btree ("fullName"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_code_embeds_repository_id_idx" ON "github_code_embeds" USING btree ("repositoryId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_code_embeds_file_path_idx" ON "github_code_embeds" USING btree ("filePath"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_code_embeds_branch_idx" ON "github_code_embeds" USING btree ("branch"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_search_cache_drive_id_idx" ON "github_search_cache" USING btree ("driveId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_search_cache_query_idx" ON "github_search_cache" USING btree ("query"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "github_search_cache_expires_at_idx" ON "github_search_cache" USING btree ("expiresAt"); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 765cde6f4..257ad4d92 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1761955168070, "tag": "0005_strange_dorian_gray", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1762300000000, + "tag": "0006_github_integration", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 938e417ab..40b8c4d3d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -13,6 +13,7 @@ export * from './schema/social'; export * from './schema/subscriptions'; export * from './schema/contact'; export * from './schema/storage'; +export * from './schema/github'; import * as auth from './schema/auth'; import * as core from './schema/core'; @@ -29,6 +30,7 @@ import * as social from './schema/social'; import * as subscriptions from './schema/subscriptions'; import * as contact from './schema/contact'; import * as storage from './schema/storage'; +import * as github from './schema/github'; export const schema = { ...auth, @@ -46,4 +48,5 @@ export const schema = { ...subscriptions, ...contact, ...storage, + ...github, }; diff --git a/packages/db/src/schema/github.ts b/packages/db/src/schema/github.ts new file mode 100644 index 000000000..d811fa95c --- /dev/null +++ b/packages/db/src/schema/github.ts @@ -0,0 +1,200 @@ +import { pgTable, text, timestamp, integer, index, boolean, jsonb } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { createId } from '@paralleldrive/cuid2'; +import { users } from './auth'; +import { drives } from './core'; + +/** + * GitHub OAuth connections for users + * Stores encrypted access tokens and user GitHub information + */ +export const githubConnections = pgTable('github_connections', { + id: text('id').primaryKey().$defaultFn(() => createId()), + userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }), + + // GitHub OAuth data + githubUserId: text('githubUserId').notNull(), + githubUsername: text('githubUsername').notNull(), + githubEmail: text('githubEmail'), + githubAvatarUrl: text('githubAvatarUrl'), + + // Encrypted access token + encryptedAccessToken: text('encryptedAccessToken').notNull(), + tokenType: text('tokenType').default('Bearer'), + scope: text('scope'), // Space-separated OAuth scopes + + // Token metadata + lastUsed: timestamp('lastUsed', { mode: 'date' }), + expiresAt: timestamp('expiresAt', { mode: 'date' }), // For OAuth apps with expiring tokens + + createdAt: timestamp('createdAt', { mode: 'date' }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { mode: 'date' }).defaultNow().notNull().$onUpdate(() => new Date()), + revokedAt: timestamp('revokedAt', { mode: 'date' }), +}, (table) => { + return { + userIdx: index('github_connections_user_id_idx').on(table.userId), + githubUserIdx: index('github_connections_github_user_id_idx').on(table.githubUserId), + }; +}); + +/** + * Connected GitHub repositories + * Represents repositories that are accessible within a PageSpace drive + */ +export const githubRepositories = pgTable('github_repositories', { + id: text('id').primaryKey().$defaultFn(() => createId()), + driveId: text('driveId').notNull().references(() => drives.id, { onDelete: 'cascade' }), + connectionId: text('connectionId').notNull().references(() => githubConnections.id, { onDelete: 'cascade' }), + + // Repository identification + githubRepoId: integer('githubRepoId').notNull(), // GitHub's numeric repo ID + owner: text('owner').notNull(), // Repository owner/org + name: text('name').notNull(), // Repository name + fullName: text('fullName').notNull(), // owner/name + + // Repository metadata + description: text('description'), + isPrivate: boolean('isPrivate').default(false).notNull(), + defaultBranch: text('defaultBranch').default('main').notNull(), + language: text('language'), // Primary language + + // Repository URLs + htmlUrl: text('htmlUrl').notNull(), + cloneUrl: text('cloneUrl').notNull(), + + // Repository statistics (cached for performance) + stargazersCount: integer('stargazersCount').default(0), + forksCount: integer('forksCount').default(0), + openIssuesCount: integer('openIssuesCount').default(0), + + // Sync metadata + lastSyncedAt: timestamp('lastSyncedAt', { mode: 'date' }), + syncError: text('syncError'), // Last sync error message if any + + // Configuration + enabled: boolean('enabled').default(true).notNull(), + branches: jsonb('branches').$type(), // Specific branches to index, null = all + + createdAt: timestamp('createdAt', { mode: 'date' }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { mode: 'date' }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => { + return { + driveIdx: index('github_repositories_drive_id_idx').on(table.driveId), + connectionIdx: index('github_repositories_connection_id_idx').on(table.connectionId), + githubRepoIdx: index('github_repositories_github_repo_id_idx').on(table.githubRepoId), + fullNameIdx: index('github_repositories_full_name_idx').on(table.fullName), + }; +}); + +/** + * Code embed blocks in documents + * Stores references to specific code snippets from GitHub repositories + * These maintain live connections to source code + */ +export const githubCodeEmbeds = pgTable('github_code_embeds', { + id: text('id').primaryKey().$defaultFn(() => createId()), + repositoryId: text('repositoryId').notNull().references(() => githubRepositories.id, { onDelete: 'cascade' }), + + // File location + filePath: text('filePath').notNull(), // Path to file in repo + branch: text('branch').notNull(), // Branch or commit SHA + + // Line range (null = entire file) + startLine: integer('startLine'), + endLine: integer('endLine'), + + // Cached content and metadata + content: text('content'), // Cached code snippet + language: text('language'), // Programming language for syntax highlighting + fileSize: integer('fileSize'), // Size in bytes + + // Version tracking + commitSha: text('commitSha'), // SHA of the commit when embedded + lastFetchedAt: timestamp('lastFetchedAt', { mode: 'date' }), + fetchError: text('fetchError'), // Error message if fetch failed + + // Display metadata + showLineNumbers: boolean('showLineNumbers').default(true).notNull(), + highlightLines: jsonb('highlightLines').$type(), // Lines to highlight + + createdAt: timestamp('createdAt', { mode: 'date' }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { mode: 'date' }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => { + return { + repositoryIdx: index('github_code_embeds_repository_id_idx').on(table.repositoryId), + filePathIdx: index('github_code_embeds_file_path_idx').on(table.filePath), + branchIdx: index('github_code_embeds_branch_idx').on(table.branch), + }; +}); + +/** + * Code search cache + * Caches search results for performance + */ +export const githubSearchCache = pgTable('github_search_cache', { + id: text('id').primaryKey().$defaultFn(() => createId()), + driveId: text('driveId').notNull().references(() => drives.id, { onDelete: 'cascade' }), + + // Search query + query: text('query').notNull(), + repositoryIds: jsonb('repositoryIds').$type(), // Repositories searched + + // Search results (cached) + results: jsonb('results').$type<{ + filePath: string; + repository: string; + matches: { + line: number; + content: string; + score: number; + }[]; + }[]>(), + + // Cache metadata + resultCount: integer('resultCount').default(0), + expiresAt: timestamp('expiresAt', { mode: 'date' }).notNull(), + + createdAt: timestamp('createdAt', { mode: 'date' }).defaultNow().notNull(), +}, (table) => { + return { + driveIdx: index('github_search_cache_drive_id_idx').on(table.driveId), + queryIdx: index('github_search_cache_query_idx').on(table.query), + expiresIdx: index('github_search_cache_expires_at_idx').on(table.expiresAt), + }; +}); + +// Relations + +export const githubConnectionsRelations = relations(githubConnections, ({ one, many }) => ({ + user: one(users, { + fields: [githubConnections.userId], + references: [users.id], + }), + repositories: many(githubRepositories), +})); + +export const githubRepositoriesRelations = relations(githubRepositories, ({ one, many }) => ({ + drive: one(drives, { + fields: [githubRepositories.driveId], + references: [drives.id], + }), + connection: one(githubConnections, { + fields: [githubRepositories.connectionId], + references: [githubConnections.id], + }), + codeEmbeds: many(githubCodeEmbeds), +})); + +export const githubCodeEmbedsRelations = relations(githubCodeEmbeds, ({ one }) => ({ + repository: one(githubRepositories, { + fields: [githubCodeEmbeds.repositoryId], + references: [githubRepositories.id], + }), +})); + +export const githubSearchCacheRelations = relations(githubSearchCache, ({ one }) => ({ + drive: one(drives, { + fields: [githubSearchCache.driveId], + references: [drives.id], + }), +})); diff --git a/packages/lib/src/services/github-service.ts b/packages/lib/src/services/github-service.ts new file mode 100644 index 000000000..ff3bda81e --- /dev/null +++ b/packages/lib/src/services/github-service.ts @@ -0,0 +1,457 @@ +/** + * GitHub Integration Service + * + * Handles all GitHub API interactions including: + * - OAuth authentication + * - Repository management + * - File browsing + * - Code search + * - Content fetching + */ + +import { encrypt, decrypt } from '../encryption-utils'; + +export interface GitHubUser { + id: number; + login: string; + email: string | null; + avatar_url: string; + name: string | null; +} + +export interface GitHubRepository { + id: number; + name: string; + full_name: string; + owner: { + login: string; + avatar_url: string; + }; + description: string | null; + private: boolean; + html_url: string; + clone_url: string; + default_branch: string; + language: string | null; + stargazers_count: number; + forks_count: number; + open_issues_count: number; + updated_at: string; +} + +export interface GitHubFileContent { + name: string; + path: string; + sha: string; + size: number; + url: string; + html_url: string; + git_url: string; + download_url: string | null; + type: 'file' | 'dir' | 'symlink' | 'submodule'; + content?: string; // Base64 encoded + encoding?: string; +} + +export interface GitHubSearchResult { + path: string; + repository: { + full_name: string; + html_url: string; + }; + score: number; + text_matches?: { + fragment: string; + matches: { + text: string; + indices: [number, number]; + }[]; + }[]; +} + +export interface GitHubCommit { + sha: string; + commit: { + message: string; + author: { + name: string; + email: string; + date: string; + }; + }; + html_url: string; +} + +export interface GitHubBranch { + name: string; + commit: { + sha: string; + url: string; + }; + protected: boolean; +} + +export class GitHubService { + private baseUrl = 'https://api.github.com'; + + constructor(private accessToken: string) {} + + /** + * Create GitHubService from encrypted token stored in database + */ + static fromEncryptedToken(encryptedToken: string): GitHubService { + const decryptedToken = decrypt(encryptedToken); + return new GitHubService(decryptedToken); + } + + /** + * Encrypt access token for storage + */ + static encryptToken(token: string): string { + return encrypt(token); + } + + /** + * Get authenticated user information + */ + async getAuthenticatedUser(): Promise { + const response = await this.request('/user'); + return response; + } + + /** + * List repositories accessible to the authenticated user + */ + async listRepositories(options: { + visibility?: 'all' | 'public' | 'private'; + affiliation?: 'owner' | 'collaborator' | 'organization_member'; + sort?: 'created' | 'updated' | 'pushed' | 'full_name'; + direction?: 'asc' | 'desc'; + per_page?: number; + page?: number; + } = {}): Promise { + const params = new URLSearchParams({ + visibility: options.visibility || 'all', + affiliation: options.affiliation || 'owner,collaborator,organization_member', + sort: options.sort || 'updated', + direction: options.direction || 'desc', + per_page: (options.per_page || 100).toString(), + page: (options.page || 1).toString(), + }); + + return this.request(`/user/repos?${params}`); + } + + /** + * Get a specific repository + */ + async getRepository(owner: string, repo: string): Promise { + return this.request(`/repos/${owner}/${repo}`); + } + + /** + * List branches for a repository + */ + async listBranches(owner: string, repo: string): Promise { + return this.request(`/repos/${owner}/${repo}/branches`); + } + + /** + * Get contents of a file or directory + */ + async getContents( + owner: string, + repo: string, + path: string, + ref?: string + ): Promise { + const params = new URLSearchParams(); + if (ref) params.set('ref', ref); + + const url = `/repos/${owner}/${repo}/contents/${path}${params.toString() ? '?' + params : ''}`; + return this.request(url); + } + + /** + * Get file content as decoded text + */ + async getFileContent( + owner: string, + repo: string, + path: string, + ref?: string + ): Promise<{ content: string; sha: string; size: number }> { + const result = await this.getContents(owner, repo, path, ref); + + if (Array.isArray(result)) { + throw new Error('Path is a directory, not a file'); + } + + if (result.type !== 'file') { + throw new Error(`Path is a ${result.type}, not a file`); + } + + if (!result.content || !result.encoding) { + throw new Error('File content not available'); + } + + if (result.encoding !== 'base64') { + throw new Error(`Unsupported encoding: ${result.encoding}`); + } + + // Decode base64 content + const content = Buffer.from(result.content, 'base64').toString('utf-8'); + + return { + content, + sha: result.sha, + size: result.size, + }; + } + + /** + * Search code across repositories + */ + async searchCode( + query: string, + options: { + repo?: string; // owner/repo format + language?: string; + path?: string; + per_page?: number; + page?: number; + } = {} + ): Promise<{ items: GitHubSearchResult[]; total_count: number }> { + let searchQuery = query; + + if (options.repo) searchQuery += ` repo:${options.repo}`; + if (options.language) searchQuery += ` language:${options.language}`; + if (options.path) searchQuery += ` path:${options.path}`; + + const params = new URLSearchParams({ + q: searchQuery, + per_page: (options.per_page || 30).toString(), + page: (options.page || 1).toString(), + }); + + return this.request<{ items: GitHubSearchResult[]; total_count: number }>( + `/search/code?${params}`, + { + headers: { + Accept: 'application/vnd.github.v3.text-match+json', // Include text matches + }, + } + ); + } + + /** + * Get commit information + */ + async getCommit(owner: string, repo: string, sha: string): Promise { + return this.request(`/repos/${owner}/${repo}/commits/${sha}`); + } + + /** + * List commits for a repository + */ + async listCommits( + owner: string, + repo: string, + options: { + sha?: string; // Branch or commit SHA + path?: string; + per_page?: number; + page?: number; + } = {} + ): Promise { + const params = new URLSearchParams({ + per_page: (options.per_page || 30).toString(), + page: (options.page || 1).toString(), + }); + + if (options.sha) params.set('sha', options.sha); + if (options.path) params.set('path', options.path); + + return this.request(`/repos/${owner}/${repo}/commits?${params}`); + } + + /** + * Check if token has specific scopes + */ + async checkScopes(): Promise { + const response = await fetch(`${this.baseUrl}/user`, { + headers: { + Authorization: `Bearer ${this.accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + const scopes = response.headers.get('X-OAuth-Scopes'); + return scopes ? scopes.split(',').map(s => s.trim()) : []; + } + + /** + * Validate that the access token is valid + */ + async validateToken(): Promise { + try { + await this.getAuthenticatedUser(); + return true; + } catch (error) { + return false; + } + } + + /** + * Generic request method with error handling + */ + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${this.accessToken}`, + Accept: 'application/vnd.github.v3+json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorBody = await response.text(); + let errorMessage = `GitHub API error: ${response.status} ${response.statusText}`; + + try { + const errorJson = JSON.parse(errorBody); + errorMessage = errorJson.message || errorMessage; + } catch { + // If parsing fails, use the default error message + } + + throw new Error(errorMessage); + } + + // Check for rate limiting + const remaining = response.headers.get('X-RateLimit-Remaining'); + const reset = response.headers.get('X-RateLimit-Reset'); + + if (remaining && parseInt(remaining) < 10) { + console.warn(`GitHub API rate limit low: ${remaining} requests remaining. Resets at ${reset}`); + } + + return response.json(); + } +} + +/** + * GitHub OAuth utilities + */ +export class GitHubOAuth { + private clientId: string; + private clientSecret: string; + private redirectUri: string; + + constructor(config: { + clientId: string; + clientSecret: string; + redirectUri: string; + }) { + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + this.redirectUri = config.redirectUri; + } + + /** + * Get OAuth authorization URL + */ + getAuthorizationUrl(state: string, scopes: string[] = ['repo', 'read:user', 'user:email']): string { + const params = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUri, + scope: scopes.join(' '), + state, + }); + + return `https://github.com/login/oauth/authorize?${params}`; + } + + /** + * Exchange authorization code for access token + */ + async exchangeCodeForToken(code: string): Promise<{ + access_token: string; + token_type: string; + scope: string; + }> { + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + code, + redirect_uri: this.redirectUri, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to exchange code for token: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`); + } + + return data; + } +} + +/** + * Detect programming language from file extension + */ +export function detectLanguageFromPath(path: string): string | null { + const ext = path.split('.').pop()?.toLowerCase(); + + const languageMap: Record = { + ts: 'typescript', + tsx: 'typescript', + js: 'javascript', + jsx: 'javascript', + py: 'python', + rb: 'ruby', + go: 'go', + rs: 'rust', + java: 'java', + c: 'c', + cpp: 'cpp', + cs: 'csharp', + php: 'php', + swift: 'swift', + kt: 'kotlin', + scala: 'scala', + sh: 'bash', + bash: 'bash', + zsh: 'bash', + sql: 'sql', + html: 'html', + css: 'css', + scss: 'scss', + sass: 'sass', + json: 'json', + yaml: 'yaml', + yml: 'yaml', + xml: 'xml', + md: 'markdown', + txt: 'text', + }; + + return ext ? languageMap[ext] || null : null; +}