// src/services/jiraPollerService.js // // Hourly Jira poller. // // Fetches unassigned tickets in the AV / Communication Services / Mobility // queue, enriches each store-scoped ticket with a compact phone or AV // status snapshot posted as a Jira comment, labels the ticket // `bot-enriched` so it's not re-processed on subsequent polls, and posts // a summary of newly-enriched tickets to a configured Webex space. // // Idempotency model — a Jira label is the source of truth. The JQL // includes `AND labels != bot-enriched`, so Jira itself only returns // unseen tickets. This survives bot restarts, deploys, and (harmlessly) // concurrent runs — no local state file, no in-memory cursor. // // Store number handling — the poller looks up the `Store Number` custom // field id via `JiraClient.getFieldIdByName()` (cached for process // lifetime) or a `JIRA_STORE_FIELD_ID` env override. If a ticket has no // store value, it's skipped entirely — no comment, no label, no summary // line — so future non-store enrichment tooling can pick it up later. // // Comment format — Jira Cloud v3 requires ADF. We emit an italic // header paragraph followed by the same markdown the /phonestatus or // /avstatus chat command would produce, converted to ADF paragraphs // via utils/markdownToAdf. This preserves clickable Meraki links, // bold device names, and paragraph structure that the previous // code-block format flattened to unformatted text. // // Failure isolation — every per-ticket step is in a per-ticket // try/catch. One flaky ticket cannot stop the batch. The label is only // added *after* `addComment` succeeds, so a transient Jira 5xx on the // comment retries next hour rather than silently swallowing the ticket. import { logger } from '../utils/logger.js'; import jira from '../integrations/jira/JiraClient.js'; import botClient from '../integrations/webex/BotClient.js'; import { collectPhoneStatus } from './phoneService.js'; import { collectDeviceStatus } from './deviceService.js'; import { classifyTicket, TicketClassifierError } from './ticketClassifier.js'; import { adfToPlainText } from '../utils/adfToPlainText.js'; import { markdownToAdfContent } from '../utils/markdownToAdf.js'; import { buildAdfComment } from '../utils/adfComment.js'; import { renderPhoneStatusMarkdown } from './renderers/phoneStatusRenderer.js'; import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.js'; const BOT_LABEL = 'bot-enriched'; const STORE_FIELD_NAME = 'Store Number'; // Hard safety cap on tickets processed per poll. AI classification // costs money AND rate-limit budget per call, so a runaway (mass ticket // import, JQL change that suddenly matches thousands of rows) should // not translate into an unbounded X.AI bill in a single hour. Backlog // drains at MAX_TICKETS_PER_POLL/hour once it exists. const MAX_TICKETS_PER_POLL = 50; // Maps classifier's `kind` to the enrichment collector. Replaces the // component-name -> collector map that used to be the source of truth // pre-AI. The classifier's output space is closed (phone|av|skip), so // this map only needs the two enrichable kinds — 'skip' short-circuits // before we get here. const KIND_TO_COLLECTOR = { phone: collectPhoneStatus, av: collectDeviceStatus, }; // Component name -> enrichment collector. Strict mapping per plan; a // ticket whose components don't match any key here is skipped (though // the poller's JQL should ensure this is never actually hit). export const COMPONENT_ROUTES = { 'Communication Services': { kind: 'phone', collect: collectPhoneStatus }, 'Mobility': { kind: 'phone', collect: collectPhoneStatus }, 'Audio Visual': { kind: 'av', collect: collectDeviceStatus }, }; // The JQL kept as a single owned constant so it's obvious in one place // and easy to audit against the spec. Any status/component change lives // here. // // Label clause gotcha: JQL's `!=` operator excludes issues where the // field is empty (documented Atlassian behavior), and brand-new tickets // almost always have zero labels. A naive `labels != bot-enriched` // therefore filters out precisely the tickets we want. The // `IS EMPTY OR ... != ...` union is the standard workaround — it // matches "no labels at all" plus "has labels, none of them are // bot-enriched". Do NOT "simplify" this back to a bare `!=`. export const POLLER_JQL = [ 'component IN ("Communication Services", "Audio Visual", Mobility)', 'AND assignee = empty', 'AND status IN ("Assign to Team", "Equipment Sent", Escalated, "High Severity Incident",', ' "In Progress", "New Request", "Not Started", Open, Pending, "Work in progress")', `AND (labels IS EMPTY OR labels != "${BOT_LABEL}")`, ].join(' '); // Resolve the Store Number field id. Env override wins so an operator // can pin it during Jira schema experiments. Otherwise cached inside // JiraClient after the first successful discovery. On error, clears the // memoization so the next poll retries. let _storeFieldIdPromise = null; async function resolveStoreFieldId() { const override = process.env.JIRA_STORE_FIELD_ID; if (override) return override; if (!_storeFieldIdPromise) { _storeFieldIdPromise = jira.getFieldIdByName(STORE_FIELD_NAME).catch((err) => { _storeFieldIdPromise = null; throw err; }); } return _storeFieldIdPromise; } // Pick the first component whose name we recognize. Jira allows a // ticket to have multiple components; we honor the first match rather // than trying to blend two enrichment kinds. export function routeForTicket(components) { for (const c of components || []) { const route = COMPONENT_ROUTES[c?.name]; if (route) return route; } return null; } // Custom fields can return strings, numbers, `{value}` objects, or // nulls depending on the field configuration. Accept the simplest cases // and require a 2-6 digit numeric value so we don't confuse "N/A" or // "unknown" text with a real store. export function extractStore(fieldValue) { if (fieldValue === null || fieldValue === undefined) return null; const raw = typeof fieldValue === 'object' ? (fieldValue.value ?? fieldValue.name ?? '') : fieldValue; const s = String(raw).trim(); const m = s.match(/^\d{2,6}$/); return m ? m[0] : null; } // Re-export the extracted `buildAdfComment` helper so any existing // callers that pulled it from this module keep working. Actual body // lives in utils/adfComment.js — pure, side-effect-free, testable in // isolation from the Jira / Webex clients this service imports. export { buildAdfComment }; /** * Poll Jira for unassigned tickets in the AV / Comm / Mobility queue, * enrich store-scoped ones with a phone/av snapshot comment, label * processed tickets `bot-enriched`, and post a summary to Webex. * * @param {object} [opts] * @param {boolean} [opts.prime=false] If true, label every matching * ticket as `bot-enriched` WITHOUT enriching or notifying. Used for * a one-time backlog prime pass via JIRA_POLLER_PRIME_ON_START=true. * @returns {Promise<{enriched: number, skipped: number, primed?: number}>} */ export async function pollNewTickets({ prime = false } = {}) { const startedAt = Date.now(); logger('jira:poller', `Poll starting${prime ? ' (PRIME mode — labels only, no enrichment/notify)' : ''}`); let storeFieldId; try { storeFieldId = await resolveStoreFieldId(); } catch (err) { logger('jira:poller', `Aborting poll — could not resolve Store Number field id: ${err.message}`, 'error'); return { enriched: 0, skipped: 0 }; } if (!storeFieldId) { logger('jira:poller', `Aborting poll — Jira field '${STORE_FIELD_NAME}' not found; set JIRA_STORE_FIELD_ID to override`, 'error'); return { enriched: 0, skipped: 0 }; } // Explicitly ask for the store custom field — the default fields list // in JiraClient.search() doesn't include it, so without this every // ticket would look store-less. `description` and `reporter` are // pulled in for the AI classifier's context payload; both are needed // per-ticket so we ask up front rather than fetching per-issue. const fields = [ 'key', 'summary', 'description', 'status', 'components', 'assignee', 'reporter', 'created', storeFieldId, ].join(','); // Emit the effective JQL + store field id every poll so operators can // paste the exact string into Jira's advanced-search UI to compare // what the bot sees vs what a human sees. Silent "0 results" from a // misconfigured component name or a service-account visibility gap // is otherwise near-impossible to diagnose. logger('jira:poller', `Executing search — storeFieldId=${storeFieldId}, JQL=${POLLER_JQL}`); let searchResult; try { searchResult = await jira.search(POLLER_JQL, fields); } catch (err) { logger('jira:poller', `Aborting poll — Jira search failed: ${err.message}`, 'error'); return { enriched: 0, skipped: 0 }; } let issues = Array.isArray(searchResult?.issues) ? searchResult.issues : []; logger('jira:poller', `Search returned ${issues.length} unlabeled candidate ticket(s)`); if (issues.length === 0) { logger('jira:poller', `Poll complete in ${((Date.now() - startedAt) / 1000).toFixed(1)}s — nothing new`); return { enriched: 0, skipped: 0 }; } // Cap enforcement — anything past MAX_TICKETS_PER_POLL waits for // next hour. Deliberately NOT sampled (first-N slice) so operators // can predict which tickets the poller will attempt each hour; the // cap is a safety net, not a load-balancer. if (issues.length > MAX_TICKETS_PER_POLL) { logger( 'jira:poller', `Ticket count ${issues.length} exceeds MAX_TICKETS_PER_POLL=${MAX_TICKETS_PER_POLL} — ` + `processing first ${MAX_TICKETS_PER_POLL}, remaining ${issues.length - MAX_TICKETS_PER_POLL} will be picked up next poll`, 'warn' ); issues = issues.slice(0, MAX_TICKETS_PER_POLL); } if (prime) { let primed = 0; for (const issue of issues) { try { await jira.addLabel(issue.key, BOT_LABEL); primed++; } catch (err) { logger('jira:poller', `PRIME: failed to label ${issue.key}: ${err.message}`, 'warn'); } } const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger('jira:poller', `PRIME complete — labeled ${primed}/${issues.length} tickets as '${BOT_LABEL}' in ${elapsedSec}s`); return { enriched: 0, skipped: issues.length - primed, primed }; } const enriched = []; // { key, summary, storeNum, kind, reason } const skipped = []; // { key, reason } let totalTokens = 0; for (const issue of issues) { const key = issue.key; const f = issue.fields || {}; const summary = f.summary || '(no summary)'; // Jira Cloud v3 returns `description` as an ADF document // (structured JSON), not plain text. Flatten it before handing to // the classifier — otherwise we'd pay tokens for JSON syntax the // model has to parse itself. The `adfToPlainText` helper is the // same one the summarizer uses on ticket bodies. const descriptionText = f.description ? (typeof f.description === 'string' ? f.description : adfToPlainText(f.description)) : ''; // Build the classifier payload. Components / raw Store Number // field value are included as *hints* — the classifier is free to // ignore them if the summary/description tell a different story. const ticketPayload = { key, summary, description: descriptionText, components: f.components || [], status: f.status?.name, reporter: f.reporter?.displayName || f.reporter?.emailAddress, storeFieldRaw: f[storeFieldId], }; let classification; try { classification = await classifyTicket(ticketPayload); totalTokens += classification.tokensUsed || 0; } catch (err) { // Skip-until-recovery per the classifier plan: AI failure means // the ticket waits for next hour rather than falling back to a // stale component-based decision. const label = err instanceof TicketClassifierError ? 'AI classification failed' : 'unexpected classifier error'; logger('jira:poller', `${key}: SKIP — ${label}: ${err.message}`, 'warn'); skipped.push({ key, reason: label }); continue; } if (classification.kind === 'skip' || !classification.storeNum) { logger('jira:poller', `${key}: SKIP — AI: ${classification.reason}`); skipped.push({ key, reason: classification.reason }); continue; } const collect = KIND_TO_COLLECTOR[classification.kind]; if (!collect) { // Belt-and-suspenders: parseAndValidate already gates kind to // phone|av|skip, but if the schema ever loosens we don't want to // silently no-op. logger('jira:poller', `${key}: SKIP — no collector for kind '${classification.kind}'`, 'warn'); skipped.push({ key, reason: `unsupported kind: ${classification.kind}` }); continue; } try { logger('jira:poller', `${key}: enriching (${classification.kind}, store ${classification.storeNum}) — AI: ${classification.reason}`); const data = await collect(classification.storeNum); // Same markdown the chat commands emit. `footer: false` strips // the "*Last checked: HH:MM*" line — a Jira comment already has // an authoritative timestamp in the header paragraph below and // Jira's own `created` field. Detailed mode always on for Jira // so triagers get the richest possible per-device info. const markdown = classification.kind === 'phone' ? renderPhoneStatusMarkdown(data, { storeNum: classification.storeNum, detailed: true, footer: false, }) : renderAvStatusMarkdown(data, { storeNum: classification.storeNum, detailed: true, footer: false, }); const headerLine = `Auto-enriched by CollabFinder — ${classification.kind} snapshot for store ${classification.storeNum} ` + `at ${new Date().toISOString()} · AI: ${classification.reason}`; const adf = buildAdfComment({ headerLine, bodyNodes: markdownToAdfContent(markdown), }); await jira.addComment(key, adf); await jira.addLabel(key, BOT_LABEL); enriched.push({ key, summary, storeNum: classification.storeNum, kind: classification.kind, reason: classification.reason, }); } catch (err) { logger('jira:poller', `${key}: enrichment failed — ${err.message}`, 'error'); skipped.push({ key, reason: `error: ${err.message}` }); } } const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger('jira:poller', `Poll complete in ${elapsedSec}s — enriched ${enriched.length}, skipped ${skipped.length}, tokens ${totalTokens}`); // Summary post — only when there's something worth reporting AND a // target room is configured. Skipped-only polls stay silent to avoid // spamming the space every hour. The AI's reason is included per // ticket so a human can spot-check misclassifications at a glance // (this is the "compensating control" for full-auto mode). const roomId = process.env.JIRA_POLLER_ROOM_ID; if (enriched.length > 0 && roomId) { const lines = [ `**${enriched.length} new ticket${enriched.length === 1 ? '' : 's'} auto-enriched** (${elapsedSec}s, ${totalTokens} AI tokens)`, '', ...enriched.map((t) => `• **${t.key}** [${t.kind === 'phone' ? 'Phone' : 'AV'}, store ${t.storeNum}] — ${t.summary}\n` + ` _AI: ${t.reason}_`, ), ]; if (skipped.length > 0) { lines.push(''); lines.push( `_${skipped.length} ticket(s) skipped: ` + `${skipped.map((s) => `${s.key} (${s.reason})`).join(', ')}_`, ); } try { await botClient.sendMarkdown(roomId, lines.join('\n')); } catch (err) { logger('jira:poller', `Failed to post summary to Webex: ${err.message}`, 'warn'); } } return { enriched: enriched.length, skipped: skipped.length, tokensUsed: totalTokens }; }