collabSupport/integrations/jira/JiraClient.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
Multi-integration Webex chat/HTTP bot that unifies phone, AV, and
network status for retail store support. Consolidates data from
Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital
signage, and OptiSigns into rich per-store status commands.

Key surfaces:
- /phonestatus, /avstatus — per-store phone & AV device reports with
  clickable Meraki deep-links and per-port detail.
- /webexhost — check/assign Webex Meetings host licenses via the
  Service App; adaptive-card confirmation flow, HTTP-API-gated.
- /offboarduser — full Webex Admin offboarding (auth revoke, device
  wipe, license removal); adaptive-card confirmation.
- /jirapoll — on-demand trigger for the hourly Jira poller.
- /bulkavstatuscsv — bulk store CSV export with concurrency limits.

Automation:
- Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier
  that categorizes unassigned tickets as phone/av/skip, extracts store
  numbers from free-text, and enriches Jira with the same detailed
  markdown the chat commands emit (converted to Jira ADF, preserves
  bold + Meraki links). Idempotent via a `bot-enriched` Jira label.

Architecture:
- Node.js 20+, ESM, Express 5, webex-node-bot-framework.
- Layered integrations (integrations/*), services (services/*),
  commands (commands/*), utils (utils/*).
- Shared markdown renderers (services/renderers/*) feed both chat
  handlers and the Jira poller so the two surfaces stay in sync.
- Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) —
  no new npm dependency.
- Node built-in test runner (`node --test tests/*.test.js`), 30 tests
  covering the converter, renderers, and poller ADF assembly.

Docker + docker-compose deployment. Config via .env
(see .env.example for the full option surface).
2026-07-01 16:55:03 -04:00

248 lines
No EOL
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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/<cloudId>
// 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<object>} 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<string|null>} 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();