UNPKG

jira-client-oauth2

Version:
382 lines (376 loc) 14.8 kB
import axios from 'axios'; // ========= INTERFACES ========= // ========= CUSTOM ERROR ========= class JiraApiError extends Error { constructor(message, status, statusText, responseData, originalError) { super(message); this.status = status; this.statusText = statusText; this.responseData = responseData; this.originalError = originalError; this.name = 'JiraApiError'; } } // src/utils/logger.ts /** * A logger that does nothing. This is the default logger for the client, * ensuring the library is silent unless a consumer provides their own logger. */ class NoOpLogger { info() { } warn() { } error() { } debug() { } } /** * An instance of the NoOpLogger to be used as the default. * This is not exported, it's an internal detail. */ const silentLogger = new NoOpLogger(); // src/jira_functions/oAuth2/JiraOAuth2Client.ts // ========= JIRA CLIENT CLASS ========= class JiraOAuth2Client { constructor(config) { const { accessToken, cloudId, apiVersion = '3' } = config; const baseUrl = `https://api.atlassian.com/ex/jira/${cloudId}`; this.logger = config.logger || silentLogger; // Helper to create a configured client with shared logic const createClient = (baseURL) => { const client = axios.create({ baseURL, headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', Accept: 'application/json', }, timeout: 30000, }); // Add interceptors for logging client.interceptors.request.use((req) => { this.logger.info(`Jira API Request: ${req.method?.toUpperCase()} ${req.baseURL}${req.url}`); return req; }, (error) => { this.logger.error('Jira API Request Error:', error); return Promise.reject(error); }); client.interceptors.response.use((res) => { this.logger.info(`Jira API Response: ${res.status} ${res.config.baseURL}${res.config.url}`); return res; }, (error) => { this.logger.error('Jira API Response Error: ', { status: error.response?.status, data: error.response?.data, url: `${error.config?.baseURL}${error.config?.url}`, }); return Promise.reject(error); }); return client; }; // Initialize clients for different Jira APIs this.jiraClient = createClient(`${baseUrl}/rest/api/${apiVersion}`); this.agileClient = createClient(`${baseUrl}/rest/agile/1.0`); this.atlassianClient = createClient('https://api.atlassian.com'); } /** * Updates the access token for all subsequent requests. * Useful for handling OAuth2 token refreshes. */ setAccessToken(newAccessToken) { const authHeader = `Bearer ${newAccessToken}`; this.jiraClient.defaults.headers.common['Authorization'] = authHeader; this.agileClient.defaults.headers.common['Authorization'] = authHeader; this.atlassianClient.defaults.headers.common['Authorization'] = authHeader; this.logger.info('Jira client access token has been updated.'); } /** * Gets a new refresh and access token. */ async refreshAccessToken(clientId, clientSecret, refreshToken) { const response = await axios.post('https://auth.atlassian.com/oauth/token', { grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, }); // Destructure BOTH the new access token and the new refresh token const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data; if (!newAccessToken || !newRefreshToken) { throw new Error('Failed to retrieve access token or new refresh token from response.'); } return response.data; } /** * Generic private method to make API calls to a specific client instance. */ async makeRequest(client, method, endpoint, data, config) { try { const response = await client.request({ method, url: endpoint, data, ...config }); // Throw if non-2xx response (just in case axios doesn’t already) if (response.status < 200 || response.status >= 300) { throw new JiraApiError(`Jira API returned status ${response.status}`, response.status, response.statusText, response.data); } // Additional Jira-specific check for "not authenticated" in successful responses if (typeof response.data === 'object' && response.data && 'errorMessages' in response.data && Array.isArray(response.data.errorMessages) && response.data.errorMessages.some((msg) => msg.toLowerCase().includes('not authenticated'))) { throw new JiraApiError('Jira API says: not authenticated (despite 2xx status)', 401, response.statusText, response.data); } return response.data; } catch (error) { const errorMessage = error.response?.data?.errorMessages?.join(', ') || error.response?.data?.message || error.message || 'An unknown Jira API error occurred'; throw new JiraApiError(errorMessage, error.response?.status, error.response?.statusText, error.response?.data, error); } } // --- Core Methods (Issues, Projects, etc.) --- /** * Creates a new issue. * (Previously addNewIssue) */ async createIssue(issueData) { if (!issueData.fields.project?.key) { throw new Error('Missing required field: project.key'); } if (!issueData.fields.issuetype?.name && !issueData.fields.issuetype?.id) { throw new Error('Missing required field: issuetype (name or id)'); } if (!issueData.fields.summary) { throw new Error('Missing required field: summary'); } return this.makeRequest(this.jiraClient, 'POST', '/issue', issueData); } /** * Retrieves an issue by its key. * (Previously findIssue) */ async getIssue(issueKey, options = {}) { const { expand, fields } = options; const params = {}; if (expand) params.expand = expand.join(','); if (fields) params.fields = fields.join(','); return this.makeRequest(this.jiraClient, 'GET', `/issue/${issueKey}`, undefined, { params }); } /** * Updates an existing issue. */ async updateIssue(issueKey, updateData) { return this.makeRequest(this.jiraClient, 'PUT', `/issue/${issueKey}`, updateData); } /** * Updates the assignee of an issue. */ async updateAssignee(issueKey, accountId) { // To unassign, pass null for accountId. Jira API expects { accountId: null } return this.makeRequest(this.jiraClient, 'PUT', `/issue/${issueKey}/assignee`, { accountId }); } /** * Searches for issues using JQL. */ async searchIssues(jql, options = {}) { const { fields, expand, maxResults, nextPageToken, fieldsByKeys, properties, failFast, reconcileIssues } = options; // Always include "key" unless user explicitly asks for "*all" or already includes it let fieldsParam; if (fields) { if (fields.includes("*all")) { fieldsParam = fields.join(","); } else if (!fields.includes("key")) { fieldsParam = ["key", ...fields].join(","); } else { fieldsParam = fields.join(","); } } else { fieldsParam = "key"; // default fallback } const params = { jql, ...(fieldsParam && { fields: fieldsParam }), ...(maxResults != null && { maxResults }), ...(nextPageToken && { nextPageToken }), ...(expand && { expand: expand.join(',') }), ...(fieldsByKeys != null && { fieldsByKeys }), ...(properties && { properties: properties.join(',') }), ...(failFast != null && { failFast }), ...(reconcileIssues && { reconcileIssues }), }; return this.makeRequest(this.jiraClient, 'GET', '/search/jql', undefined, { params }); } /** * Searches for all Epics for a given project. */ async getEpics(projectKey) { const allEpics = []; let nextPageToken; const MAX_RESULTS = 1000; const jql = `project = "${projectKey}" AND issuetype = Epic`; while (true) { const options = { maxResults: MAX_RESULTS, ...(nextPageToken ? { nextPageToken } : {}), fields: ['*all', "-comments", "-attachements"] }; const result = await this.searchIssues(jql, options); if (result.issues && result.issues.length > 0) { allEpics.push(...result.issues); } if (result.nextPageToken) { nextPageToken = result.nextPageToken; } else { break; } } return allEpics; } /** * Creates a link between two issues. */ async linkIssues(linkRequest) { return this.makeRequest(this.jiraClient, 'POST', '/issueLink', linkRequest); } // --- Transitions and Workflows --- /** * Retrieves the available transitions for an issue. * (Previously listTransitions) */ async getTransitions(issueKey) { return this.makeRequest(this.jiraClient, 'GET', `/issue/${issueKey}/transitions`); } /** * Transitions an issue to a new status. */ async transitionIssue(issueKey, transitionId, fields, comment) { const data = { transition: { id: transitionId }, }; if (fields) { data.fields = fields; } if (comment) { data.update = { comment: [ { add: { body: { type: 'doc', version: 1, content: [ { type: 'paragraph', content: [ { type: 'text', text: comment, }, ], }, ], }, }, }, ], }; } return this.makeRequest(this.jiraClient, 'POST', `/issue/${issueKey}/transitions`, data); } // --- Projects and Schemes --- /** * Retrieves all projects visible to the user. */ async getProjects() { const response = await this.makeRequest(this.jiraClient, 'GET', '/project/search'); return response.values; } /** * Retrieves a single project by its key or ID. */ async getProject(projectKeyOrId) { return this.makeRequest(this.jiraClient, 'GET', `/project/${projectKeyOrId}`); } /** * Creates a new project. Note: Requires admin permissions. */ async createProject(projectData) { return this.makeRequest(this.jiraClient, 'POST', '/project', projectData); } /** * Searches for all Issues for a given project. */ async getIssuesForProject(projectKey, options = {}) { const { nextPageToken: initialToken, maxResults: userMaxResults, fields = ['*all', "-comments", "-attachements"], jql: additionalJql, } = options; const allIssues = []; let nextPageToken = initialToken; const MAX_RESULTS = userMaxResults || 1000; // Build JQL query - combine project filter with additional JQL if provided let jql = `project = "${projectKey}"`; if (additionalJql) { jql = `${jql} AND (${additionalJql})`; } // If user specified maxResults, we only fetch that many results total const shouldFetchAll = !userMaxResults; let remainingResults = userMaxResults || Infinity; while (remainingResults > 0) { const currentMaxResults = shouldFetchAll ? MAX_RESULTS : Math.min(MAX_RESULTS, remainingResults); const searchOptions = { maxResults: currentMaxResults, ...(nextPageToken ? { nextPageToken } : {}), }; if (fields && fields.length > 0) { searchOptions.fields = fields; } const result = await this.searchIssues(jql, searchOptions); if (result.issues && result.issues.length > 0) { allIssues.push(...result.issues); } if (shouldFetchAll) { // Keep going as long as there’s a nextPageToken if (result.nextPageToken) { nextPageToken = result.nextPageToken; } else { break; } } else { // User specified maxResults - respect the limit remainingResults -= result.issues.length; if (result.nextPageToken && remainingResults > 0) { nextPageToken = result.nextPageToken; } else { break; } } } return allIssues; } // --- User-related Methods --- /** * Retrieves the profile of the current user. */ async getCurrentUser() { // This uses the global Atlassian API, not the Jira-specific one return this.makeRequest(this.atlassianClient, 'GET', '/me'); } /** * Deletes an issue. * Note: This is a permanent action. */ async deleteIssue(issueKey) { return this.makeRequest(this.jiraClient, 'DELETE', `/issue/${issueKey}`); } } // src/index.ts export { JiraApiError, JiraOAuth2Client as default }; //# sourceMappingURL=index.js.map