// src/integrations/jira/JiraClient.js import axios from 'axios'; import { logger } from '../../utils/logger.js'; // Shared error logger for write endpoints. A stock axios error like // "Request failed with status code 401" tells you nothing about WHY // Jira rejected the call — the actionable detail (missing scope, wrong // permission, wrong shape) lives in the response body and, for 401s, // the WWW-Authenticate header. Log all of it. Falls back gracefully // when Jira returns an HTML error page (empty JSON body). function logJiraWriteError(op, key, err) { const status = err.response?.status || 'unknown'; const data = err.response?.data; // Jira's structured errors have `errorMessages[]` and/or // `errors{}`. Auth failures at the api.atlassian.com gateway often // return `{ code, message }` instead. Show whatever's present. let detail; if (typeof data === 'string') { detail = data.slice(0, 300); } else if (data && typeof data === 'object') { const parts = []; if (Array.isArray(data.errorMessages) && data.errorMessages.length) { parts.push(`errorMessages=${data.errorMessages.join('; ')}`); } if (data.errors && Object.keys(data.errors).length) { parts.push(`errors=${JSON.stringify(data.errors)}`); } if (data.message) parts.push(`message=${data.message}`); if (data.code) parts.push(`code=${data.code}`); detail = parts.length ? parts.join(' | ') : JSON.stringify(data).slice(0, 300); } else { detail = err.message; } // WWW-Authenticate carries Bearer/OAuth error hints for connected // apps — e.g. `error="insufficient_scope", scope="write:comment:jira"`. const wwwAuth = err.response?.headers?.['www-authenticate']; const wwwPart = wwwAuth ? ` | WWW-Authenticate=${wwwAuth}` : ''; logger( 'jira:client', `Failed to ${op} on ${key} [${status}] - ${detail}${wwwPart}`, 'error', ); } class JiraClient { static #instance = null; // Field-name -> field-id cache populated on first getFieldIdByName() call. // null means "not yet fetched"; a Map means we've called /field once and // memoized the whole schema for this process lifetime. Custom-field ids // don't change on a running Jira site, so no TTL is needed. #fieldIdCache = null; constructor() { if (JiraClient.#instance) return JiraClient.#instance; const cloudId = process.env.JIRA_CLOUD_ID; const baseURL = process.env.JIRA_BASE_URL; const email = process.env.JIRA_EMAIL; const token = process.env.JIRA_API_TOKEN; if (!email || !token) { logger('jira:client', 'Missing JIRA_EMAIL or JIRA_API_TOKEN – Jira features will fail', 'error'); throw new Error('Missing Jira configuration'); } let effectiveBase; if (cloudId) { // New service account / Atlassian Cloud API gateway form (required for some accounts) // e.g. https://api.atlassian.com/ex/jira/f52e9ac9-59a4-4465-8c04-6a05e368107c effectiveBase = `https://api.atlassian.com/ex/jira/${cloudId}`; logger('jira:client', `Using Jira Cloud ID base (service account): ${cloudId}`); } else if (baseURL) { effectiveBase = baseURL; } else { logger('jira:client', 'Missing JIRA_BASE_URL or JIRA_CLOUD_ID – Jira features will fail', 'error'); throw new Error('Missing Jira configuration'); } // Basic Auth (email:apiToken). Works for both classic site URLs and the api.atlassian.com/ex/jira/ // gateway used by service accounts / connected apps. const auth = Buffer.from(`${email}:${token}`).toString('base64'); this.axios = axios.create({ baseURL: `${effectiveBase}/rest/api/3`, timeout: 15000, headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json', }, }); JiraClient.#instance = this; logger('jira:client', 'JiraClient initialized successfully'); } /** * Search Jira using JQL */ async search(jql, fields = 'key,summary,status,resolution,assignee,created,resolved,components', maxResults = null) { const limit = maxResults || parseInt(process.env.JIRA_MAX_RESULTS) || 20; try { const payload = { jql: jql.trim(), fields: fields.split(',').map(f => f.trim()), maxResults: limit, expand: "comment" }; // Use /search/jql as shown in your working Postman call const response = await this.axios.post('/search/jql', payload); const issueCount = response.data.issues?.length || 0; logger('jira:client', `Search successful - ${issueCount} issues returned`, 'debug'); return response.data; } catch (err) { const status = err.response?.status || 'unknown'; const errorMsg = err.response?.data?.errorMessages?.join(', ') || err.response?.data?.message || err.message; logger('jira:client', `Search failed [${status}] - ${errorMsg}`, 'error'); throw err; } } /** * Get full details for a single ticket */ async getTicket(key) { try { const response = await this.axios.get(`/issue/${key}`, { params: { expand: 'comment,renderedFields' } }); const commentCount = response.data.fields?.comment?.comments?.length || 0; logger('jira:client', `Fetched ticket ${key} (${commentCount} comments)`, 'debug'); return response.data; } catch (err) { const status = err.response?.status; logger('jira:client', `Failed to fetch ticket ${key} [${status}]`, 'error'); throw err; } } /** * Post a comment on an issue. * * Jira Cloud REST v3 requires the `body` to be an ADF (Atlassian * Document Format) document — a JSON tree, not markdown or wiki * markup. Callers are responsible for constructing valid ADF; see * services/jiraPollerService.js:buildAdfComment for the pattern we * use for status snapshots. * * @param {string} key Issue key, e.g. 'SUPPORT-1234' * @param {object} adfBody ADF document (root object with type:'doc') * @returns {Promise} The created comment payload from Jira. */ async addComment(key, adfBody) { try { const response = await this.axios.post(`/issue/${key}/comment`, { body: adfBody, }); logger('jira:client', `Added comment on ${key} (id=${response.data?.id || 'unknown'})`, 'debug'); return response.data; } catch (err) { logJiraWriteError('addComment', key, err); throw err; } } /** * Append a label to an issue. Uses the `update` semantics of PUT * /issue/{key} which is safe for concurrent labelers — Jira merges * the add into the existing label set rather than replacing it. If * the label is already present, Jira treats the PUT as a no-op. * * @param {string} key Issue key. * @param {string} label Label to add (no spaces; Jira rejects * labels containing whitespace). */ async addLabel(key, label) { try { await this.axios.put(`/issue/${key}`, { update: { labels: [{ add: label }] }, }); logger('jira:client', `Added label '${label}' on ${key}`, 'debug'); } catch (err) { logJiraWriteError('addLabel', key, err); throw err; } } /** * Resolve a custom-field display name (e.g. 'Store Number') to its * numeric id (e.g. 'customfield_10042'). Fetches the org-wide field * schema once via GET /field on first call, then serves from an * in-process cache — the schema doesn't drift on a live Jira site, * so no TTL is warranted. * * Returns null if no field matches (case-insensitive) so callers can * fall back to an env-var override without an exception. * * @param {string} name Display name to search for. * @returns {Promise} Field id (e.g. 'customfield_10042') or null. */ async getFieldIdByName(name) { if (!name) return null; if (!this.#fieldIdCache) { try { const response = await this.axios.get('/field'); const fields = Array.isArray(response.data) ? response.data : []; this.#fieldIdCache = new Map(); for (const f of fields) { if (f?.name && f?.id) { // Lowercase the key so lookups are case-insensitive. // Later duplicates overwrite earlier ones, which is // typically fine — but we log if we spot a collision // so operators know two fields share a display name. const key = f.name.toLowerCase(); if (this.#fieldIdCache.has(key)) { logger('jira:client', `Field name collision on '${f.name}': keeping ${f.id}, previously ${this.#fieldIdCache.get(key)}`, 'warn'); } this.#fieldIdCache.set(key, f.id); } } logger('jira:client', `Cached ${this.#fieldIdCache.size} Jira field name/id mappings`); } catch (err) { const status = err.response?.status || 'unknown'; logger('jira:client', `Failed to fetch field schema [${status}] - ${err.message}`, 'error'); // Leave cache null so a future call can retry rather than // permanently caching an empty result on a transient failure. throw err; } } return this.#fieldIdCache.get(name.toLowerCase()) || null; } } // Export as singleton export default new JiraClient();