Skip to main content
Glama
client.ts9.21 kB
/** * Jira API client with authentication and rate limiting. * Provides a centralized interface for all Jira API interactions. * @module jira/client */ import type { JiraConfig } from '../types/index.js'; import { JiraApiError, AuthenticationError } from '../utils/errors.js'; import { createLogger } from '../utils/logger.js'; import { RateLimiter, createJiraRateLimiter } from '../utils/rate-limiter.js'; const logger = createLogger('jira-client'); /** * HTTP methods supported by the client. */ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; /** * Request options for API calls. */ export interface RequestOptions { /** Query parameters */ params?: Record<string, string | number | boolean | undefined>; /** Request body */ body?: unknown; /** Additional headers */ headers?: Record<string, string>; /** Whether to skip rate limiting */ skipRateLimit?: boolean; } /** * Jira API client class. * Handles authentication, rate limiting, and request/response processing. */ export class JiraClient { private readonly baseUrl: string; private readonly authHeader: string; private readonly rateLimiter: RateLimiter; private readonly maxRetries: number = 3; private readonly retryDelay: number = 1000; private readonly projectsFilter: string[] | null; constructor(config: JiraConfig, rateLimit: number = 100) { this.baseUrl = config.baseUrl; this.authHeader = this.createAuthHeader(config.email, config.apiToken); this.rateLimiter = createJiraRateLimiter(rateLimit); this.projectsFilter = config.projectsFilter ? config.projectsFilter .split(',') .map((p) => p.trim().toUpperCase()) .filter((p) => p.length > 0) : null; logger.info('Jira client initialized', { baseUrl: this.baseUrl, projectsFilter: this.projectsFilter, }); } /** * Creates the Basic Auth header from email and API token. */ private createAuthHeader(email: string, apiToken: string): string { const credentials = Buffer.from(`${email}:${apiToken}`).toString('base64'); return `Basic ${credentials}`; } /** * Builds a URL with query parameters. */ private buildUrl( path: string, params?: Record<string, string | number | boolean | undefined> ): string { const url = new URL(path, this.baseUrl); if (params) { for (const [key, value] of Object.entries(params)) { if (value !== undefined) { url.searchParams.set(key, String(value)); } } } return url.toString(); } /** * Makes an HTTP request to the Jira API. */ private async request<T>( method: HttpMethod, path: string, options: RequestOptions = {} ): Promise<T> { const { params, body, headers, skipRateLimit } = options; // Apply rate limiting unless skipped if (!skipRateLimit) { await this.rateLimiter.waitForToken(); } const url = this.buildUrl(path, params); const requestHeaders: Record<string, string> = { Authorization: this.authHeader, 'Content-Type': 'application/json', Accept: 'application/json', ...headers, }; logger.debug('Making API request', { method, path, params, }); let lastError: Error | null = null; for (let attempt = 1; attempt <= this.maxRetries; attempt++) { try { const response = await fetch(url, { method, headers: requestHeaders, body: body ? JSON.stringify(body) : undefined, }); // Handle rate limiting response if (response.status === 429) { const retryAfter = parseInt( response.headers.get('Retry-After') || '60', 10 ); logger.warn('Rate limited by Jira API', { retryAfter, attempt }); await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000) ); continue; } // Handle authentication errors if (response.status === 401) { throw new AuthenticationError( 'Invalid Jira credentials. Please check your email and API token.' ); } // Handle forbidden errors if (response.status === 403) { throw new JiraApiError( 'Access forbidden. Check your permissions.', 403 ); } // Handle not found errors if (response.status === 404) { throw await JiraApiError.fromResponse(response, { path }); } // Handle other error responses if (!response.ok) { throw await JiraApiError.fromResponse(response, { path, method }); } // Handle empty responses if ( response.status === 204 || response.headers.get('Content-Length') === '0' ) { return {} as T; } const data = await response.json(); logger.debug('API request successful', { path, status: response.status, }); return data as T; } catch (error) { lastError = error as Error; // Don't retry authentication or forbidden errors if ( error instanceof AuthenticationError || (error instanceof JiraApiError && (error.statusCode === 403 || error.statusCode === 404)) ) { throw error; } // Retry on network errors or 5xx errors if (attempt < this.maxRetries) { const delay = this.retryDelay * Math.pow(2, attempt - 1); logger.warn('Request failed, retrying', { attempt, delay, error: (error as Error).message, }); await new Promise((resolve) => setTimeout(resolve, delay)); } } } logger.error('All retry attempts failed', lastError as Error); throw lastError; } /** * Makes a GET request. */ async get<T>(path: string, options?: RequestOptions): Promise<T> { return this.request<T>('GET', path, options); } /** * Makes a POST request. */ async post<T>(path: string, options?: RequestOptions): Promise<T> { return this.request<T>('POST', path, options); } /** * Makes a PUT request. */ async put<T>(path: string, options?: RequestOptions): Promise<T> { return this.request<T>('PUT', path, options); } /** * Makes a PATCH request. */ async patch<T>(path: string, options?: RequestOptions): Promise<T> { return this.request<T>('PATCH', path, options); } /** * Makes a DELETE request. */ async delete<T>(path: string, options?: RequestOptions): Promise<T> { return this.request<T>('DELETE', path, options); } /** * Gets the remaining rate limit tokens. */ getRemainingRateLimit(): number { return this.rateLimiter.getRemainingTokens(); } /** * Validates the connection to Jira. * @throws {AuthenticationError} If credentials are invalid */ async validateConnection(): Promise<boolean> { try { await this.get('/rest/api/3/myself'); logger.info('Jira connection validated successfully'); return true; } catch (error) { if (error instanceof AuthenticationError) { throw error; } logger.error('Failed to validate Jira connection', error as Error); return false; } } /** * Gets the configured projects filter. * @returns Array of allowed project keys (uppercase) or null if no filter */ getProjectsFilter(): string[] | null { return this.projectsFilter; } /** * Checks if an issue key is allowed by the projects filter. * @param issueKey - The issue key (e.g., "PROJ-123") * @returns true if allowed, false if filtered out */ isProjectAllowed(issueKey: string): boolean { if (!this.projectsFilter) { return true; } const projectKey = issueKey.split('-')[0]?.toUpperCase() ?? ''; return this.projectsFilter.includes(projectKey); } /** * Validates that an issue key is allowed by the projects filter. * @param issueKey - The issue key to validate * @throws {Error} If the project is not in the allowed list */ validateProjectAccess(issueKey: string): void { if (!this.isProjectAllowed(issueKey)) { const projectKey = issueKey.split('-')[0]?.toUpperCase() ?? ''; throw new Error( `Issue with project prefix '${projectKey}' is restricted by configuration` ); } } } /** * Singleton client instance. */ let clientInstance: JiraClient | null = null; /** * Initializes the Jira client singleton. */ export function initializeClient( config: JiraConfig, rateLimit?: number ): JiraClient { clientInstance = new JiraClient(config, rateLimit); return clientInstance; } /** * Gets the Jira client singleton. * @throws {Error} If client is not initialized */ export function getClient(): JiraClient { if (!clientInstance) { throw new Error( 'Jira client not initialized. Call initializeClient first.' ); } return clientInstance; } /** * Resets the client singleton (useful for testing). */ export function resetClient(): void { clientInstance = null; }

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/icy-r/jira-mcp'

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