Skip to main content
Glama
github.service.ts19.4 kB
/** * GitHub Service Module * =================== * * This module provides a service for interacting with the GitHub API. * It encapsulates the Octokit client and provides a cleaner interface * for performing GitHub operations. */ import { Octokit } from '@octokit/rest'; import { getApplicationConfiguration } from '../../configuration/index.export.js'; import { StructuredLoggingUtility } from '../../utilities/structured.logging.utility.js'; import { ApplicationErrorHandlingUtility } from '../../utilities/error.handling.utility.js'; import { asRecord, asRequestParameters, safeAccess, extractResponseHeaders, hasHeaders } from '../../utilities/type.casting.utility.js'; import { createSafeTimeout, withRetry } from '../../utilities/promise.utility.js'; import { GitHubRepositoryEntity, GitHubBranchEntity, GitHubIssueEntity, GitHubPullRequestEntity, GitHubReleaseEntity } from '../../types/entity.definition.types.js'; import { GitHubServiceInterface, CreateRepositoryParams, GetRepositoryParams, ListRepositoriesParams, CreateBranchParams, DeleteBranchParams, ListBranchesParams, CreateIssueParams, ListIssuesParams, CreatePullRequestParams, MergePullRequestParams, UpdatePullRequestParams, ListPullRequestsParams, UpdateFileParams, CreateReleaseParams } from './github.service.types.js'; import { getGitHubResponseMapper } from './github.response.mapper.js'; import { getGitHubRateLimiter } from './github.rate.limiter.js'; /** * Maximum number of retry attempts for rate-limited or transient errors */ const MAX_RETRY_ATTEMPTS = 3; /** * Base delay for exponential backoff in milliseconds */ const BASE_RETRY_DELAY_MS = 1000; /** * Configuration refresh interval (15 minutes) */ const CONFIG_REFRESH_INTERVAL_MS = 15 * 60 * 1000; /** * Implementation of GitHubServiceInterface that uses Octokit to interact with GitHub API */ export class GitHubService implements GitHubServiceInterface { private static instance: GitHubService; private _octokit: Octokit; private mapper = getGitHubResponseMapper(); private rateLimiter = getGitHubRateLimiter(); private _config = getApplicationConfiguration(); private lastConfigRefresh: number = Date.now(); /** * Type guard for GitHub API error objects */ private isGitHubApiError(error: unknown): error is { status?: number; message?: string; response?: { headers?: Record<string, string> } } { return typeof error === 'object' && error !== null && (('status' in error) || ('message' in error)); } /** * Private constructor to enforce singleton pattern */ private constructor() { // Initialize the Octokit client this._octokit = this.createOctokitClient(); StructuredLoggingUtility.recordInfoEntry('GitHub service initialized'); } /** * Creates a new Octokit client with current configuration * @returns New Octokit client instance */ private createOctokitClient(): Octokit { return new Octokit({ auth: this._config.githubToken, request: { timeout: this._config.apiTimeoutMs } }); } /** * Get the current Octokit client, refreshing if needed * @returns Current Octokit client */ private get octokit(): Octokit { this.refreshConfigurationIfNeeded(); return this._octokit; } /** * Get the current configuration, refreshing if needed * @returns Current application configuration */ private get config(): any { this.refreshConfigurationIfNeeded(); return this._config; } /** * Refresh the configuration if the refresh interval has elapsed */ private refreshConfigurationIfNeeded(): void { const now = Date.now(); const timeSinceLastRefresh = now - this.lastConfigRefresh; if (timeSinceLastRefresh >= CONFIG_REFRESH_INTERVAL_MS) { try { // Get fresh configuration const newConfig = getApplicationConfiguration(); // Check if token or other critical settings changed const tokenChanged = this._config.githubToken !== newConfig.githubToken; const timeoutChanged = this._config.apiTimeoutMs !== newConfig.apiTimeoutMs; if (tokenChanged || timeoutChanged) { StructuredLoggingUtility.recordInfoEntry('GitHub service configuration changed, recreating client', { tokenChanged, timeoutChanged }); // Update config this._config = newConfig; // Create new Octokit instance with new config this._octokit = this.createOctokitClient(); } else { // Just update config this._config = newConfig; } this.lastConfigRefresh = now; } catch (error) { StructuredLoggingUtility.recordErrorEntry('Failed to refresh GitHub service configuration', { error: error instanceof Error ? error.message : String(error) }); // Continue with existing configuration } } } /** * Explicitly refresh the configuration and client * This can be called when a token refresh is known to have occurred */ public refreshConfiguration(): void { try { // Force config refresh this._config = getApplicationConfiguration(); this._octokit = this.createOctokitClient(); this.lastConfigRefresh = Date.now(); StructuredLoggingUtility.recordInfoEntry('GitHub service configuration refreshed manually'); } catch (error) { StructuredLoggingUtility.recordErrorEntry('Failed to manually refresh GitHub service configuration', { error: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Gets the singleton instance of the GitHub service * @returns The GitHub service instance */ public static getInstance(): GitHubService { if (!GitHubService.instance) { GitHubService.instance = new GitHubService(); } return GitHubService.instance; } /** * Executes a GitHub API request with rate limiting and retry logic * * @param operationName - Name of the operation being performed * @param apiCall - Function that makes the API call * @returns Result of the API call */ private async executeWithRateLimiting<T>( operationName: string, apiCall: () => Promise<T> ): Promise<T> { let retryCount = 0; while (true) { try { // Check if we should wait due to rate limiting await this.rateLimiter.checkRateLimit(); // Execute the API call const response: unknown = await apiCall(); // Update rate limit info from response headers if available if (hasHeaders(response)) { const headers = extractResponseHeaders(response); this.rateLimiter.updateRateLimitFromHeaders(headers); } return response as T; } catch (error: unknown) { // Safely extract error information let statusCode = 0; let errorMessage = 'Unknown error'; let isRateLimitError = false; if (this.isGitHubApiError(error)) { statusCode = error.status || 0; errorMessage = error.message || 'GitHub API error'; isRateLimitError = statusCode === 403 && errorMessage.includes('API rate limit exceeded'); } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } // Check if this is a transient error that we can retry const isTransientError = statusCode >= 500 || statusCode === 429 || isRateLimitError; // If we can retry and haven't exceeded max retries if (isTransientError && retryCount < MAX_RETRY_ATTEMPTS) { retryCount++; // Calculate exponential backoff delay const delay = BASE_RETRY_DELAY_MS * Math.pow(2, retryCount); StructuredLoggingUtility.recordWarnEntry(`GitHub API error, retrying operation`, { operationName, retryCount, delay, errorStatus: statusCode, errorMessage }); // Handle rate limit errors specially if (isRateLimitError) { // Safely extract retry-after header let retryAfter: string | undefined; if (this.isGitHubApiError(error) && error.response && error.response.headers) { retryAfter = error.response.headers['retry-after']; } await this.rateLimiter.handleRateLimitExceeded(retryAfter); } else { // Wait according to exponential backoff with proper cleanup const timeout = createSafeTimeout(delay, `Retry for ${operationName}`); await timeout.promise; } // Continue to next retry attempt continue; } // We either can't retry or have exceeded max retries throw this.handleGitHubApiError(error, operationName); } } } /** * Generic method to handle GitHub API errors */ private handleGitHubApiError(error: unknown, operationName: string): never { let errorMessage: string; if (this.isGitHubApiError(error)) { errorMessage = error.message || 'Unknown GitHub API error'; } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } StructuredLoggingUtility.recordErrorEntry(`GitHub API error in ${operationName}`, { error: errorMessage }); throw ApplicationErrorHandlingUtility.createGithubApiError( `GitHub API error in ${operationName}: ${errorMessage}`, { originalError: error } ); } // #region Repository Management /** * Creates a new GitHub repository * @param params Repository creation parameters * @returns The created repository entity */ async createRepository(params: CreateRepositoryParams): Promise<GitHubRepositoryEntity> { return this.executeWithRateLimiting('createRepository', async () => { StructuredLoggingUtility.recordDebugEntry('Creating repository', asRecord(params)); const response = await this.octokit.repos.createForAuthenticatedUser(asRequestParameters(params)); return this.mapper.mapRepositoryResponse(response.data); }); } /** * Gets information about a GitHub repository * @param params Repository request parameters * @returns The repository entity */ async getRepository(params: GetRepositoryParams): Promise<GitHubRepositoryEntity> { return this.executeWithRateLimiting('getRepository', async () => { StructuredLoggingUtility.recordDebugEntry('Getting repository', asRecord(params)); const response = await this.octokit.repos.get(asRequestParameters(params)); return this.mapper.mapRepositoryResponse(response.data); }); } /** * Lists repositories for the authenticated user * @param params Repository listing parameters * @returns Array of repository entities */ async listRepositories(params: ListRepositoriesParams): Promise<GitHubRepositoryEntity[]> { return this.executeWithRateLimiting('listRepositories', async () => { StructuredLoggingUtility.recordDebugEntry('Listing repositories', asRecord(params)); const response = await this.octokit.repos.listForAuthenticatedUser(asRequestParameters(params)); return this.mapper.mapRepositoriesResponse(response.data); }); } // #endregion // #region Branch Management /** * Creates a new branch in a repository * @param params Branch creation parameters * @returns The created branch entity */ async createBranch(params: CreateBranchParams): Promise<GitHubBranchEntity> { return this.executeWithRateLimiting('createBranch', async () => { const { owner, repo, branch, sha } = params; StructuredLoggingUtility.recordDebugEntry('Creating branch', asRecord({ owner, repo, branch, sha })); const response = await this.octokit.git.createRef({ owner: owner, repo: repo, ref: `refs/heads/${branch}`, sha: sha }); // Since createRef doesn't return full branch info, we need to get it separately const branchResponse = await this.octokit.repos.getBranch({ owner: owner, repo: repo, branch: branch }); return this.mapper.mapBranchResponse(branchResponse.data); }); } /** * Deletes a branch from a repository * @param params Branch deletion parameters */ async deleteBranch(params: DeleteBranchParams): Promise<void> { return this.executeWithRateLimiting('deleteBranch', async () => { const { owner, repo, branch } = params; StructuredLoggingUtility.recordDebugEntry('Deleting branch', asRecord({ owner, repo, branch })); await this.octokit.git.deleteRef({ owner: owner, repo: repo, ref: `heads/${branch}` }); StructuredLoggingUtility.recordInfoEntry('Branch deleted successfully', asRecord({ owner, repo, branch })); }); } /** * Lists branches in a repository * @param params Branch listing parameters * @returns Array of branch entities */ async listBranches(params: ListBranchesParams): Promise<GitHubBranchEntity[]> { return this.executeWithRateLimiting('listBranches', async () => { StructuredLoggingUtility.recordDebugEntry('Listing branches', asRecord(params)); const response = await this.octokit.repos.listBranches(asRequestParameters(params)); return this.mapper.mapBranchesResponse(response.data); }); } // #endregion // #region Issue Management /** * Creates a new issue in a repository * @param params Issue creation parameters * @returns The created issue entity */ async createIssue(params: CreateIssueParams): Promise<GitHubIssueEntity> { return this.executeWithRateLimiting('createIssue', async () => { StructuredLoggingUtility.recordDebugEntry('Creating issue', asRecord(params)); const response = await this.octokit.issues.create(asRequestParameters(params)); return this.mapper.mapIssueResponse(response.data); }); } /** * Lists issues in a repository * @param params Issue listing parameters * @returns Array of issue entities */ async listIssues(params: ListIssuesParams): Promise<GitHubIssueEntity[]> { return this.executeWithRateLimiting('listIssues', async () => { StructuredLoggingUtility.recordDebugEntry('Listing issues', asRecord(params)); // Handle label conversion for the API const apiParams = { ...params, labels: params.labels?.join(',') }; const response = await this.octokit.issues.listForRepo(apiParams); return this.mapper.mapIssuesResponse(response.data); }); } // #endregion // #region Pull Request Management /** * Creates a new pull request * @param params Pull request creation parameters * @returns The created pull request entity */ async createPullRequest(params: CreatePullRequestParams): Promise<GitHubPullRequestEntity> { return this.executeWithRateLimiting('createPullRequest', async () => { StructuredLoggingUtility.recordDebugEntry('Creating pull request', asRecord(params)); const response = await this.octokit.pulls.create(asRequestParameters(params)); return this.mapper.mapPullRequestResponse(response.data); }); } /** * Merges a pull request * @param params Pull request merge parameters * @returns Merge result information */ async mergePullRequest(params: MergePullRequestParams): Promise<{ merged: boolean; message: string; sha?: string }> { return this.executeWithRateLimiting('mergePullRequest', async () => { StructuredLoggingUtility.recordDebugEntry('Merging pull request', asRecord(params)); const response = await this.octokit.pulls.merge(asRequestParameters(params)); return { merged: response.data.merged, message: response.data.message, sha: response.data.sha }; }); } /** * Updates an existing pull request * @param params Pull request update parameters * @returns The updated pull request entity */ async updatePullRequest(params: UpdatePullRequestParams): Promise<GitHubPullRequestEntity> { return this.executeWithRateLimiting('updatePullRequest', async () => { StructuredLoggingUtility.recordDebugEntry('Updating pull request', asRecord(params)); const response = await this.octokit.pulls.update(asRequestParameters(params)); return this.mapper.mapPullRequestResponse(response.data); }); } /** * Lists pull requests in a repository * @param params Pull request listing parameters * @returns Array of pull request entities */ async listPullRequests(params: ListPullRequestsParams): Promise<GitHubPullRequestEntity[]> { return this.executeWithRateLimiting('listPullRequests', async () => { StructuredLoggingUtility.recordDebugEntry('Listing pull requests', asRecord(params)); const response = await this.octokit.pulls.list(asRequestParameters(params)); return this.mapper.mapPullRequestsResponse(response.data); }); } // #endregion // #region File Management /** * Creates or updates a file in a repository * @param params File update parameters * @returns Information about the commit and updated file */ async updateFile(params: UpdateFileParams): Promise<{ commitSha: string; content: { sha: string } }> { return this.executeWithRateLimiting('updateFile', async () => { StructuredLoggingUtility.recordDebugEntry('Updating file', { owner: params.owner, repo: params.repo, path: params.path, branch: params.branch }); const response = await this.octokit.repos.createOrUpdateFileContents(asRequestParameters(params)); return { // Cast to string for TypeScript - safe because we provide a default empty string commitSha: safeAccess(response.data.commit, commit => commit.sha, '') || '', content: { // We know this is safe because safeAccess guarantees a non-undefined return value. sha: safeAccess(response.data.content, content => content.sha, '') || '' } }; }); } // #endregion // #region Release Management /** * Creates a new release * @param params Release creation parameters * @returns The created release entity */ async createRelease(params: CreateReleaseParams): Promise<GitHubReleaseEntity> { return this.executeWithRateLimiting('createRelease', async () => { StructuredLoggingUtility.recordDebugEntry('Creating release', asRecord(params)); const response = await this.octokit.repos.createRelease(asRequestParameters(params)); return this.mapper.mapReleaseResponse(response.data); }); } // #endregion } /** * Gets the GitHub service instance * @returns The GitHub service instance */ export function getGitHubService(): GitHubServiceInterface { return GitHubService.getInstance(); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cyanheads/github-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server