jira-client-oauth2
Version:
Jira client class with OAuth2.0 support.
382 lines (376 loc) • 14.8 kB
JavaScript
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