// 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 jql = `key = "${issueKey}" AND project = ${JIRA_PROJECT_KEY}`; const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 }); if (result.ok && result.data.issues && result.data.issues.length > 0) { return { ok: true, data: result.data.issues[0] }; } if (result.ok && (!result.data.issues || result.data.issues.length === 0)) { return { ok: false, status: 404, body: 'Issue not found' }; } return result; } /** * 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 AND project = ${JIRA_PROJECT_KEY}`; 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 fieldList = encodeURIComponent(fields.join(',')); const encodedJql = encodeURIComponent(jql); const queryString = `?jql=${encodedJql}&fields=${fieldList}&maxResults=${maxResults}&startAt=${startAt}`; const res = await jiraGet('/rest/api/2/search' + queryString); 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 };