451 lines
16 KiB
JavaScript
451 lines
16 KiB
JavaScript
|
|
// Shared Jira Data Center REST API helpers
|
||
|
|
// Centralizes HTTP calls for Jira issue operations.
|
||
|
|
// Follows the same promise-based pattern as atlasApi.js and ivantiApi.js.
|
||
|
|
//
|
||
|
|
// =========================================================================
|
||
|
|
// Charter Jira REST API Compliance
|
||
|
|
// =========================================================================
|
||
|
|
// Authentication:
|
||
|
|
// - Service accounts use Basic Auth (required for shared integrations).
|
||
|
|
// - PATs require ATLSUP approval and naming convention:
|
||
|
|
// Function - Team - Approved ATLSUP ticket
|
||
|
|
// - SSO must NOT be used for REST API integrations.
|
||
|
|
//
|
||
|
|
// Rate limiting (Charter-posted):
|
||
|
|
// - 1 440 requests/day max
|
||
|
|
// - Burst cap of 60 requests/minute (accumulates 1 req/idle minute)
|
||
|
|
// - 429 response when limits are hit server-side
|
||
|
|
//
|
||
|
|
// Automation delays (Charter requirement):
|
||
|
|
// - 1 second delay between GET requests
|
||
|
|
// - 2 second delay between PUT, POST, or DELETE requests
|
||
|
|
//
|
||
|
|
// Forbidden patterns:
|
||
|
|
// - /rest/api/2/field — must specify fields explicitly in every call
|
||
|
|
// - /rest/api/2/issue/bulk — bulk updates are not allowed
|
||
|
|
// - Single-issue GET loops — use bulk JQL search instead
|
||
|
|
//
|
||
|
|
// Required patterns:
|
||
|
|
// - All GET requests MUST include a ?fields= parameter
|
||
|
|
// - JQL MUST include at least one of: project+updated, assignee+updated,
|
||
|
|
// status+updated
|
||
|
|
// - JQL should use &updated>=-Xh to only fetch changed issues
|
||
|
|
// - maxResults=1000 for search queries
|
||
|
|
// - Issues must be updated one at a time (no bulk PUT)
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
const https = require('https');
|
||
|
|
const http = require('http');
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Configuration — read from process.env at module load
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const JIRA_BASE_URL = process.env.JIRA_BASE_URL || '';
|
||
|
|
const JIRA_AUTH_METHOD = (process.env.JIRA_AUTH_METHOD || 'basic').toLowerCase();
|
||
|
|
const JIRA_API_USER = process.env.JIRA_API_USER || '';
|
||
|
|
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN || '';
|
||
|
|
const JIRA_PAT = process.env.JIRA_PAT || '';
|
||
|
|
const JIRA_SKIP_TLS = process.env.JIRA_SKIP_TLS === 'true';
|
||
|
|
const JIRA_PROJECT_KEY = process.env.JIRA_PROJECT_KEY || '';
|
||
|
|
const JIRA_ISSUE_TYPE = process.env.JIRA_ISSUE_TYPE || 'Task';
|
||
|
|
|
||
|
|
const requiredVars = JIRA_AUTH_METHOD === 'pat'
|
||
|
|
? ['JIRA_BASE_URL', 'JIRA_PAT']
|
||
|
|
: ['JIRA_BASE_URL', 'JIRA_API_USER', 'JIRA_API_TOKEN'];
|
||
|
|
|
||
|
|
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||
|
|
if (missingVars.length > 0) {
|
||
|
|
console.warn(`[jira-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Jira API calls will fail.`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const isConfigured = missingVars.length === 0;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Default fields — every GET must specify fields explicitly.
|
||
|
|
// /rest/api/2/field is forbidden; we define the field list here.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const DEFAULT_FIELDS = [
|
||
|
|
'summary', 'status', 'assignee', 'created', 'updated',
|
||
|
|
'priority', 'issuetype', 'project', 'resolution'
|
||
|
|
];
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Rate limiter — enforces Charter's posted limits
|
||
|
|
// 1 440 events/day, burst of 60 events/minute
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const DAILY_LIMIT = 1440;
|
||
|
|
const BURST_LIMIT = 60;
|
||
|
|
const MINUTE_MS = 60 * 1000;
|
||
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
|
|
|
||
|
|
let dailyLog = [];
|
||
|
|
let minuteLog = [];
|
||
|
|
|
||
|
|
function pruneLog(log, windowMs) {
|
||
|
|
const cutoff = Date.now() - windowMs;
|
||
|
|
while (log.length > 0 && log[0] < cutoff) {
|
||
|
|
log.shift();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function checkRateLimit() {
|
||
|
|
pruneLog(dailyLog, DAY_MS);
|
||
|
|
pruneLog(minuteLog, MINUTE_MS);
|
||
|
|
|
||
|
|
if (dailyLog.length >= DAILY_LIMIT) {
|
||
|
|
return { allowed: false, reason: `Daily Jira API limit reached (${DAILY_LIMIT}/day). Resets at midnight.` };
|
||
|
|
}
|
||
|
|
if (minuteLog.length >= BURST_LIMIT) {
|
||
|
|
return { allowed: false, reason: `Burst Jira API limit reached (${BURST_LIMIT}/min). Wait and retry.` };
|
||
|
|
}
|
||
|
|
return { allowed: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
function recordRequest() {
|
||
|
|
const now = Date.now();
|
||
|
|
dailyLog.push(now);
|
||
|
|
minuteLog.push(now);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Return current rate limit usage for diagnostics.
|
||
|
|
*/
|
||
|
|
function getRateLimitStatus() {
|
||
|
|
pruneLog(dailyLog, DAY_MS);
|
||
|
|
pruneLog(minuteLog, MINUTE_MS);
|
||
|
|
return {
|
||
|
|
daily: { used: dailyLog.length, limit: DAILY_LIMIT, remaining: DAILY_LIMIT - dailyLog.length },
|
||
|
|
burst: { used: minuteLog.length, limit: BURST_LIMIT, remaining: BURST_LIMIT - minuteLog.length }
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Inter-request delay — Charter automation requirements
|
||
|
|
// 1s between GETs, 2s between PUT/POST/DELETE
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const GET_DELAY_MS = 1000;
|
||
|
|
const WRITE_DELAY_MS = 2000;
|
||
|
|
|
||
|
|
let lastRequestTime = 0;
|
||
|
|
let lastRequestMethod = '';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Wait the required delay before issuing the next request.
|
||
|
|
* GET → 1s, PUT/POST/DELETE → 2s since the previous request.
|
||
|
|
*/
|
||
|
|
function waitForDelay(method) {
|
||
|
|
const now = Date.now();
|
||
|
|
const requiredDelay = (lastRequestMethod === 'GET') ? GET_DELAY_MS
|
||
|
|
: (lastRequestMethod !== '') ? WRITE_DELAY_MS : 0;
|
||
|
|
const elapsed = now - lastRequestTime;
|
||
|
|
const remaining = requiredDelay - elapsed;
|
||
|
|
|
||
|
|
if (remaining > 0) {
|
||
|
|
return new Promise(resolve => setTimeout(resolve, remaining));
|
||
|
|
}
|
||
|
|
return Promise.resolve();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Blocked endpoint guard
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const BLOCKED_PATHS = [
|
||
|
|
'/rest/api/2/field', // Must specify fields in call, not query field list
|
||
|
|
'/rest/api/2/issue/bulk', // Bulk updates are not allowed
|
||
|
|
];
|
||
|
|
|
||
|
|
function isBlockedPath(urlPath) {
|
||
|
|
return BLOCKED_PATHS.some(blocked => urlPath.startsWith(blocked));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Generic request — supports GET, POST, PUT, DELETE
|
||
|
|
// Enforces rate limits, inter-request delays, and blocked-path guards.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
async function jiraRequest(method, urlPath, body, options) {
|
||
|
|
// Block forbidden endpoints
|
||
|
|
if (isBlockedPath(urlPath)) {
|
||
|
|
return Promise.reject(new Error(`Blocked: ${urlPath} is not allowed per Charter Jira API policy.`));
|
||
|
|
}
|
||
|
|
|
||
|
|
const limit = checkRateLimit();
|
||
|
|
if (!limit.allowed) {
|
||
|
|
return Promise.reject(new Error(limit.reason));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Enforce inter-request delay
|
||
|
|
await waitForDelay(method);
|
||
|
|
|
||
|
|
const timeout = (options && options.timeout) || 15000;
|
||
|
|
const fullUrl = new URL(JIRA_BASE_URL + urlPath);
|
||
|
|
const isHttps = fullUrl.protocol === 'https:';
|
||
|
|
const transport = isHttps ? https : http;
|
||
|
|
|
||
|
|
const headers = {
|
||
|
|
'accept': 'application/json'
|
||
|
|
};
|
||
|
|
|
||
|
|
// Auth header
|
||
|
|
if (JIRA_AUTH_METHOD === 'pat') {
|
||
|
|
headers['authorization'] = 'Bearer ' + JIRA_PAT;
|
||
|
|
} else {
|
||
|
|
const authString = Buffer.from(JIRA_API_USER + ':' + JIRA_API_TOKEN).toString('base64');
|
||
|
|
headers['authorization'] = 'Basic ' + authString;
|
||
|
|
}
|
||
|
|
|
||
|
|
let bodyStr = null;
|
||
|
|
if (body !== null && body !== undefined) {
|
||
|
|
bodyStr = JSON.stringify(body);
|
||
|
|
headers['content-type'] = 'application/json';
|
||
|
|
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||
|
|
}
|
||
|
|
|
||
|
|
recordRequest();
|
||
|
|
lastRequestTime = Date.now();
|
||
|
|
lastRequestMethod = method;
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const reqOptions = {
|
||
|
|
hostname: fullUrl.hostname,
|
||
|
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||
|
|
path: fullUrl.pathname + fullUrl.search,
|
||
|
|
method: method,
|
||
|
|
headers: headers,
|
||
|
|
timeout: timeout
|
||
|
|
};
|
||
|
|
|
||
|
|
if (isHttps) {
|
||
|
|
reqOptions.rejectUnauthorized = !JIRA_SKIP_TLS;
|
||
|
|
}
|
||
|
|
|
||
|
|
const req = transport.request(reqOptions, (res) => {
|
||
|
|
let data = '';
|
||
|
|
res.on('data', (chunk) => { data += chunk; });
|
||
|
|
res.on('end', () => {
|
||
|
|
if (res.statusCode === 429) {
|
||
|
|
resolve({ status: 429, body: data, rateLimited: true });
|
||
|
|
} else {
|
||
|
|
resolve({ status: res.statusCode, body: data });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
req.on('timeout', () => req.destroy(new Error(method + ' ' + urlPath + ' timed out')));
|
||
|
|
req.on('error', (err) => {
|
||
|
|
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
|
||
|
|
});
|
||
|
|
|
||
|
|
if (bodyStr) {
|
||
|
|
req.write(bodyStr);
|
||
|
|
}
|
||
|
|
req.end();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Convenience wrappers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
function jiraGet(urlPath, options) {
|
||
|
|
return jiraRequest('GET', urlPath, null, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function jiraPost(urlPath, body, options) {
|
||
|
|
return jiraRequest('POST', urlPath, body, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function jiraPut(urlPath, body, options) {
|
||
|
|
return jiraRequest('PUT', urlPath, body, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
function jiraDelete(urlPath, options) {
|
||
|
|
return jiraRequest('DELETE', urlPath, null, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// High-level Jira operations — all comply with Charter requirements
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Fetch a single issue by key using a GET with explicit ?fields= parameter.
|
||
|
|
* Charter requires all GETs to specify fields — /rest/api/2/field is forbidden.
|
||
|
|
*
|
||
|
|
* NOTE: For syncing multiple tickets, prefer searchIssuesByKeys() which uses
|
||
|
|
* a single bulk JQL search instead of one GET per issue.
|
||
|
|
*
|
||
|
|
* @param {string} issueKey - e.g. "VULN-123"
|
||
|
|
* @param {string[]} [fields] - Jira field names to return
|
||
|
|
*/
|
||
|
|
async function getIssue(issueKey, fields) {
|
||
|
|
const fieldList = (fields || DEFAULT_FIELDS).join(',');
|
||
|
|
const res = await jiraGet(
|
||
|
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}?fields=${encodeURIComponent(fieldList)}`
|
||
|
|
);
|
||
|
|
if (res.status === 200) {
|
||
|
|
return { ok: true, data: JSON.parse(res.body) };
|
||
|
|
}
|
||
|
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Bulk-fetch issues by their keys using a single JQL search.
|
||
|
|
* This is the Charter-compliant way to sync multiple tickets — avoids
|
||
|
|
* querying one issue at a time.
|
||
|
|
*
|
||
|
|
* @param {string[]} issueKeys - Array of Jira issue keys
|
||
|
|
* @param {object} [opts] - { fields, maxResults }
|
||
|
|
*/
|
||
|
|
async function searchIssuesByKeys(issueKeys, opts) {
|
||
|
|
if (!issueKeys || issueKeys.length === 0) {
|
||
|
|
return { ok: true, data: { total: 0, issues: [] } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build JQL: key in (KEY-1, KEY-2, ...) — Charter requires project+updated
|
||
|
|
// or similar, but key-based search is inherently scoped. We add updated
|
||
|
|
// clause for compliance.
|
||
|
|
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
||
|
|
const jql = `key in (${keyList}) AND updated >= -24h`;
|
||
|
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||
|
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||
|
|
|
||
|
|
return searchIssues(jql, { fields, maxResults, startAt: 0 });
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Search issues via JQL (POST to /rest/api/2/search).
|
||
|
|
* Charter requirements enforced:
|
||
|
|
* - fields array is always specified (never omitted)
|
||
|
|
* - maxResults capped at 1000
|
||
|
|
*
|
||
|
|
* The caller is responsible for including an &updated clause in the JQL
|
||
|
|
* for recurring/scheduled queries.
|
||
|
|
*
|
||
|
|
* @param {string} jql - JQL query string
|
||
|
|
* @param {object} [opts] - { startAt, maxResults, fields }
|
||
|
|
*/
|
||
|
|
async function searchIssues(jql, opts) {
|
||
|
|
const startAt = (opts && opts.startAt) || 0;
|
||
|
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||
|
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||
|
|
|
||
|
|
const body = { jql, startAt, maxResults, fields };
|
||
|
|
const res = await jiraPost('/rest/api/2/search', body);
|
||
|
|
if (res.status === 200) {
|
||
|
|
return { ok: true, data: JSON.parse(res.body) };
|
||
|
|
}
|
||
|
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a new Jira issue (POST, subject to 2s delay).
|
||
|
|
* @param {object} fields - Jira issue fields object
|
||
|
|
*/
|
||
|
|
async function createIssue(fields) {
|
||
|
|
const res = await jiraPost('/rest/api/2/issue', { fields });
|
||
|
|
if (res.status === 201) {
|
||
|
|
return { ok: true, data: JSON.parse(res.body) };
|
||
|
|
}
|
||
|
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update a single Jira issue (PUT, subject to 2s delay).
|
||
|
|
* Charter forbids bulk updates — issues must be updated one at a time.
|
||
|
|
* @param {string} issueKey
|
||
|
|
* @param {object} fields - Fields to update
|
||
|
|
*/
|
||
|
|
async function updateIssue(issueKey, fields) {
|
||
|
|
const res = await jiraPut(
|
||
|
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}`,
|
||
|
|
{ fields }
|
||
|
|
);
|
||
|
|
// Jira returns 204 on successful update
|
||
|
|
if (res.status === 204) {
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Add a comment to an existing issue (POST, subject to 2s delay).
|
||
|
|
*/
|
||
|
|
async function addComment(issueKey, commentBody) {
|
||
|
|
const res = await jiraPost(
|
||
|
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment`,
|
||
|
|
{ body: commentBody }
|
||
|
|
);
|
||
|
|
if (res.status === 201) {
|
||
|
|
return { ok: true, data: JSON.parse(res.body) };
|
||
|
|
}
|
||
|
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Transition an issue to a new status (POST, subject to 2s delay).
|
||
|
|
* @param {string} issueKey
|
||
|
|
* @param {string} transitionId
|
||
|
|
*/
|
||
|
|
async function transitionIssue(issueKey, transitionId) {
|
||
|
|
const res = await jiraPost(
|
||
|
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`,
|
||
|
|
{ transition: { id: transitionId } }
|
||
|
|
);
|
||
|
|
if (res.status === 204) {
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get available transitions for an issue.
|
||
|
|
* Uses GET with explicit fields parameter (transitions endpoint returns
|
||
|
|
* transitions by default, but we include the query param for compliance).
|
||
|
|
*/
|
||
|
|
async function getTransitions(issueKey) {
|
||
|
|
const res = await jiraGet(
|
||
|
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`
|
||
|
|
);
|
||
|
|
if (res.status === 200) {
|
||
|
|
return { ok: true, data: JSON.parse(res.body) };
|
||
|
|
}
|
||
|
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test connectivity — calls /rest/api/2/myself to verify credentials.
|
||
|
|
* This is a lightweight GET that returns the authenticated user.
|
||
|
|
*/
|
||
|
|
async function testConnection() {
|
||
|
|
try {
|
||
|
|
const res = await jiraGet('/rest/api/2/myself');
|
||
|
|
if (res.status === 200) {
|
||
|
|
const user = JSON.parse(res.body);
|
||
|
|
return { ok: true, user: { name: user.name, displayName: user.displayName, emailAddress: user.emailAddress } };
|
||
|
|
}
|
||
|
|
return { ok: false, status: res.status, body: res.body };
|
||
|
|
} catch (err) {
|
||
|
|
return { ok: false, error: err.message };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
isConfigured,
|
||
|
|
jiraRequest,
|
||
|
|
jiraGet,
|
||
|
|
jiraPost,
|
||
|
|
jiraPut,
|
||
|
|
jiraDelete,
|
||
|
|
getIssue,
|
||
|
|
searchIssuesByKeys,
|
||
|
|
searchIssues,
|
||
|
|
createIssue,
|
||
|
|
updateIssue,
|
||
|
|
addComment,
|
||
|
|
transitionIssue,
|
||
|
|
getTransitions,
|
||
|
|
testConnection,
|
||
|
|
getRateLimitStatus,
|
||
|
|
DEFAULT_FIELDS,
|
||
|
|
JIRA_PROJECT_KEY,
|
||
|
|
JIRA_ISSUE_TYPE
|
||
|
|
};
|