/** * GitHubService interface and API implementation * Abstracts GitHub API operations for testability */ import type { GitHubUser, GitHubOrg, GetReposOptions, CreateRepoOptions, CloneOptions, } from "./types.ts"; import type { GitHubRepoInfo, OperationResult } from "../types/index.ts"; import { errorToString } from "../utils/errors.ts"; import { withRetry, shouldRetryGitHubError } from "../utils/retry.ts"; import { GITHUB_API, GIT } from "../constants.ts"; // ============================================================================ // GitHubService Interface // ============================================================================ export interface GitHubService { /** * Check if a GitHub token is available * @returns true if GITHUB_TOKEN or GH_TOKEN environment variable is set */ hasToken(): boolean; /** * Get the GitHub token from environment * @returns The token string or null if not set */ getToken(): string | null; /** * Get the authenticated user's information * @returns Promise resolving to user data * @throws {GitHubAPIError} If authentication fails */ getAuthenticatedUser(): Promise; /** * Get organizations the authenticated user belongs to * @returns Promise resolving to array of organization data */ getUserOrgs(): Promise; /** * Get repositories owned by the authenticated user * @param options - Filter options for repositories * @returns Promise resolving to array of repository data */ getUserRepos(options?: GetReposOptions): Promise; /** * Get repositories for a specific organization * @param org - Organization name * @param options - Filter options for repositories * @returns Promise resolving to array of repository data */ getOrgRepos(org: string, options?: GetReposOptions): Promise; /** * Get all repositories accessible to the authenticated user * @param options - Filter options for repositories * @returns Promise resolving to array of repository data */ getAllRepos(options?: GetReposOptions): Promise; /** * Get a specific repository by owner and name * @param owner - Repository owner (user or organization) * @param name - Repository name * @returns Promise resolving to repository data */ getRepo(owner: string, name: string): Promise; /** * Search for repositories using GitHub's search API * @param query - Search query * @param options - Search options (sort, order, perPage) * @returns Promise resolving to array of repository data */ searchRepos(query: string, options?: { sort?: string; order?: string; perPage?: number }): Promise; /** * Create a new GitHub repository * @param options - Repository creation options * @returns Promise with success status and optional URL or error */ createRepo(options: CreateRepoOptions): Promise<{ success: boolean; url?: string; error?: string }>; /** * Archive a GitHub repository * @param ownerRepo - Repository in "owner/repo" format or just "repo" for authenticated user * @returns Promise with success status and optional error */ archiveRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }>; /** * Unarchive a GitHub repository * @param ownerRepo - Repository in "owner/repo" format or just "repo" for authenticated user * @returns Promise with success status and optional error */ unarchiveRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }>; /** * Delete a GitHub repository * @param ownerRepo - Repository in "owner/repo" format or just "repo" for authenticated user * @returns Promise with success status and optional error */ deleteRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }>; /** * Clone a GitHub repository * @param repo - Repository data * @param options - Clone options (useSSH, targetDir) * @returns Promise resolving to operation result */ cloneRepo(repo: GitHubRepoInfo, options: CloneOptions): Promise; } // ============================================================================ // GitHub API Response Types (internal) // ============================================================================ interface GitHubApiRepo { id: number; name: string; full_name: string; description: string | null; html_url: string; ssh_url: string; clone_url: string; private: boolean; archived: boolean; fork: boolean; created_at: string; updated_at: string; pushed_at: string | null; /** GitHub API reports repository size in kilobytes. */ size: number; stargazers_count: number; forks_count: number; open_issues_count: number; watchers_count: number; topics: string[]; license: { name: string; } | null; has_issues: boolean; has_wiki: boolean; has_discussions: boolean; language: string | null; default_branch: string; owner: { login: string; type: "User" | "Organization"; }; } // ============================================================================ // Helper: Convert API response to our type // ============================================================================ function apiToInfo(repo: GitHubApiRepo): GitHubRepoInfo { return { name: repo.name, fullName: repo.full_name, owner: repo.owner.login, description: repo.description, htmlUrl: repo.html_url, sshUrl: repo.ssh_url, cloneUrl: repo.clone_url, isPrivate: repo.private, isArchived: repo.archived, isFork: repo.fork, pushedAt: repo.pushed_at ? new Date(repo.pushed_at) : null, updatedAt: repo.updated_at ? new Date(repo.updated_at) : null, defaultBranch: repo.default_branch, language: repo.language, size: repo.size, stargazersCount: repo.stargazers_count, forksCount: repo.forks_count, openIssuesCount: repo.open_issues_count, watchersCount: repo.watchers_count, topics: repo.topics ?? [], license: repo.license?.name ?? null, hasIssues: repo.has_issues, hasWiki: repo.has_wiki, hasDiscussions: repo.has_discussions, }; } // ============================================================================ // GitHub API Error // ============================================================================ export class GitHubAPIError extends Error { constructor( message: string, public status: number, public response?: unknown ) { super(message); this.name = "GitHubAPIError"; } } // ============================================================================ // Helper: Validate GitHub token format // ============================================================================ /** * Validate GitHub token format * GitHub tokens can be: * - Classic tokens: 40 character hex string * - Fine-grained PATs: ghp_xxxx (40 chars after prefix) * - GitHub App tokens: ghs_xxxx * - OAuth tokens: gho_xxxx */ function validateTokenFormat(token: string): boolean { const patterns = [ /^ghp_[a-zA-Z0-9]{36}$/, // Personal access tokens (new) /^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$/, // Fine-grained PATs /^gho_[a-zA-Z0-9]{35,40}$/, // OAuth tokens (flexible length) /^ghs_[a-zA-Z0-9]{36}$/, // GitHub App tokens /^ghr_[a-zA-Z0-9]{36}$/, // Refresh tokens /^[a-f0-9]{40}$/, // Classic tokens (40 hex chars) ]; return patterns.some(pattern => pattern.test(token)); } // ============================================================================ // API Implementation // ============================================================================ export const apiGitHubService: GitHubService = { hasToken(): boolean { // Treat empty strings as missing const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; return token !== null; }, getToken(): string | null { // Prefer GITHUB_TOKEN, fall back to GH_TOKEN, ignore empty strings const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; if (token && !validateTokenFormat(token)) { console.warn('Warning: GITHUB_TOKEN format appears invalid. Expected ghp_*, github_pat_*, or 40-char hex string.'); } return token; }, async getAuthenticatedUser(): Promise { return await githubFetch("/user", this.getToken()!); }, async getUserOrgs(): Promise { return await githubFetch("/user/orgs", this.getToken()!); }, async getUserRepos(options?: GetReposOptions): Promise { const { type = "owner", sort = "pushed", direction = "desc", includeArchived = false, includeForks = true, } = options ?? {}; const allRepos: GitHubRepoInfo[] = []; let page = 1; const perPage = GITHUB_API.PAGE_SIZE; while (true) { const repos = await githubFetch( `/user/repos?type=${type}&sort=${sort}&direction=${direction}&per_page=${perPage}&page=${page}`, this.getToken()! ); if (repos.length === 0) break; const filtered = repos .filter((repo) => { if (!includeArchived && repo.archived) return false; if (!includeForks && repo.fork) return false; return true; }) .map(apiToInfo); allRepos.push(...filtered); page++; if (repos.length < perPage) break; } return allRepos; }, async getOrgRepos(org: string, options?: GetReposOptions): Promise { const { type = "all", sort = "pushed", direction = "desc", includeArchived = false, } = options ?? {}; const allRepos: GitHubRepoInfo[] = []; let page = 1; const perPage = GITHUB_API.PAGE_SIZE; while (true) { const repos = await githubFetch( `/orgs/${org}/repos?type=${type}&sort=${sort}&direction=${direction}&per_page=${perPage}&page=${page}`, this.getToken()! ); if (repos.length === 0) break; const filtered = repos .filter((repo) => !includeArchived || !repo.archived) .map(apiToInfo); allRepos.push(...filtered); page++; if (repos.length < perPage) break; } return allRepos; }, async getAllRepos(options?: GetReposOptions): Promise { const { includeOrgs = true, includeArchived = false, includeForks = true, } = options ?? {}; // Get user's own repos const userRepos = await this.getUserRepos({ type: "owner", includeArchived, includeForks, }); if (!includeOrgs) { return userRepos; } // Get orgs and their repos const orgs = await this.getUserOrgs(); const orgRepoPromises = orgs.map((org) => this.getOrgRepos(org.login, { includeArchived }) ); const orgReposArrays = await Promise.all(orgRepoPromises); const orgRepos = orgReposArrays.flat(); // Combine and dedupe by fullName const repoMap = new Map(); for (const repo of [...userRepos, ...orgRepos]) { repoMap.set(repo.fullName, repo); } return Array.from(repoMap.values()); }, async getRepo(owner: string, name: string): Promise { const repo = await githubFetch(`/repos/${owner}/${name}`, this.getToken()!); return apiToInfo(repo); }, async searchRepos( query: string, options?: { sort?: string; order?: string; perPage?: number } ): Promise { const { sort, order = "desc", perPage = 30 } = options ?? {}; const sortParam = sort ? `&sort=${sort}` : ""; const result = await githubFetch<{ items: GitHubApiRepo[] }>( `/search/repositories?q=${encodeURIComponent(query)}${sortParam}&order=${order}&per_page=${perPage}`, this.getToken()! ); return result.items.map(apiToInfo); }, async createRepo( options: CreateRepoOptions ): Promise<{ success: boolean; url?: string; error?: string }> { const { name, description, isPrivate = true, localPath } = options; const token = this.getToken(); if (!token) { return { success: false, error: "GITHUB_TOKEN not set" }; } try { // Create repo via GitHub API const response = await fetch(`${GITHUB_API.BASE_URL}/user/repos`, { method: "POST", headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": GITHUB_API.API_VERSION, "Content-Type": "application/json", }, body: JSON.stringify({ name, description: description ?? undefined, private: isPrivate, auto_init: false, }), }); if (!response.ok) { const error = await response.json().catch(() => ({})) as { message?: string }; return { success: false, error: error.message ?? `API error: ${response.statusText}`, }; } const repoData = await response.json() as { html_url: string; ssh_url: string; clone_url: string }; // If localPath provided, set up local repo connection if (localPath) { const sshUrl = repoData.ssh_url; // Add remote const addResult = await Bun.$`git -C ${localPath} remote add origin ${sshUrl}`.quiet().nothrow(); if (addResult.exitCode !== 0) { // Remote might already exist, try to set url instead await Bun.$`git -C ${localPath} remote set-url origin ${sshUrl}`.quiet().nothrow(); } // Get current branch and push const branchResult = await Bun.$`git -C ${localPath} rev-parse --abbrev-ref HEAD`.quiet().text(); const branch = branchResult.trim() || GIT.DEFAULT_BRANCH; const pushResult = await Bun.$`git -C ${localPath} push -u origin ${branch}`.quiet().nothrow(); if (pushResult.exitCode !== 0) { // Treat partial setup as failure so callers don't display ✓ for a // half-finished operation. Surface the URL so users can recover. return { success: false, url: repoData.html_url, error: `Repo created at ${repoData.html_url} but push failed - push manually`, }; } } return { success: true, url: repoData.html_url }; } catch (error) { return { success: false, error: errorToString(error), }; } }, async archiveRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }> { const token = this.getToken(); if (!token) { return { success: false, error: "GITHUB_TOKEN not set" }; } try { // Resolve owner/repo if just repo name provided let fullName = ownerRepo; if (!ownerRepo.includes("/")) { const user = await this.getAuthenticatedUser(); fullName = `${user.login}/${ownerRepo}`; } const response = await fetch(`${GITHUB_API.BASE_URL}/repos/${fullName}`, { method: "PATCH", headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": GITHUB_API.API_VERSION, "Content-Type": "application/json", }, body: JSON.stringify({ archived: true }), }); if (!response.ok) { const error = await response.json().catch(() => ({})) as { message?: string }; return { success: false, error: error.message ?? `API error: ${response.statusText}`, }; } return { success: true }; } catch (error) { return { success: false, error: errorToString(error), }; } }, async unarchiveRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }> { const token = this.getToken(); if (!token) { return { success: false, error: "GITHUB_TOKEN not set" }; } try { // Resolve owner/repo if just repo name provided let fullName = ownerRepo; if (!ownerRepo.includes("/")) { const user = await this.getAuthenticatedUser(); fullName = `${user.login}/${ownerRepo}`; } const response = await fetch(`${GITHUB_API.BASE_URL}/repos/${fullName}`, { method: "PATCH", headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": GITHUB_API.API_VERSION, "Content-Type": "application/json", }, body: JSON.stringify({ archived: false }), }); if (!response.ok) { const error = await response.json().catch(() => ({})) as { message?: string }; return { success: false, error: error.message ?? `API error: ${response.statusText}`, }; } return { success: true }; } catch (error) { return { success: false, error: errorToString(error), }; } }, async deleteRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }> { const token = this.getToken(); if (!token) { return { success: false, error: "GITHUB_TOKEN not set" }; } try { // Resolve owner/repo if just repo name provided let fullName = ownerRepo; if (!ownerRepo.includes("/")) { const user = await this.getAuthenticatedUser(); fullName = `${user.login}/${ownerRepo}`; } const response = await fetch(`${GITHUB_API.BASE_URL}/repos/${fullName}`, { method: "DELETE", headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": GITHUB_API.API_VERSION, }, }); if (!response.ok) { const error = await response.json().catch(() => ({})) as { message?: string }; return { success: false, error: error.message ?? `API error: ${response.statusText}`, }; } return { success: true }; } catch (error) { return { success: false, error: errorToString(error), }; } }, async cloneRepo(repo: GitHubRepoInfo, options: CloneOptions): Promise { const { useSSH = true, targetDir } = options; const url = useSSH ? repo.sshUrl : repo.cloneUrl; const start = Date.now(); try { const result = await Bun.$`git clone ${url} ${targetDir}`.quiet(); if (result.exitCode === 0) { return { success: true, projectPath: targetDir, operation: "clone", message: `Cloned ${repo.fullName}`, duration: Date.now() - start, }; } return { success: false, projectPath: targetDir, operation: "clone", error: "Clone failed", duration: Date.now() - start, }; } catch (error) { return { success: false, projectPath: targetDir, operation: "clone", error: errorToString(error), duration: Date.now() - start, }; } }, }; // ============================================================================ // Helper: Make authenticated GitHub API request // ============================================================================ async function githubFetch(endpoint: string, token: string): Promise { return withRetry( async () => { if (!token) { throw new GitHubAPIError("GITHUB_TOKEN not set", 401); } const url = endpoint.startsWith("https://") ? endpoint : `${GITHUB_API.BASE_URL}${endpoint}`; const response = await fetch(url, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": GITHUB_API.API_VERSION, }, }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new GitHubAPIError( `GitHub API error: ${response.statusText}`, response.status, error ); } return response.json() as Promise; }, { maxAttempts: GITHUB_API.MAX_RETRIES, initialDelay: GITHUB_API.INITIAL_RETRY_DELAY, shouldRetry: shouldRetryGitHubError, } ); } // ============================================================================ // Default Export // ============================================================================ export const defaultGitHubService = apiGitHubService;