// 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 — Jira labels are the source of truth. Two are // used: `bot-enriched` after a successful comment, `bot-skipped` after // the AI classifier decides the ticket is out-of-scope (kind='skip', // no store number, or an unroutable kind). The JQL excludes BOTH so // Jira itself only returns tickets the bot hasn't looked at yet. This // stops the poller from paying AI tokens re-classifying the same // "not for us" tickets every hour, and survives bot restarts, // deploys, and (harmlessly) concurrent runs — no local state file, // no in-memory cursor. Transient failures (AI down, Meraki 5xx, Jira // comment 5xx) intentionally leave the ticket unlabeled so it retries // next hour. // // 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 /voicestatus 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 { resolveEnrichmentPlan } from './jiraPoller/enrichmentRules.js'; import { formatEnrichedTicketLine, formatSkippedTicketRef, } from './jiraPoller/formatSummary.js'; import { getJiraBrowseUrl } from '../utils/jiraBrowseUrl.js'; import { runEnrichmentChecks } from './jiraPoller/runEnrichment.js'; const BOT_LABEL = 'bot-enriched'; // Applied when the AI classifier decides a ticket is out-of-scope for // enrichment (kind='skip', no store number, or an unroutable kind). // Distinct from `bot-enriched` so operators can query the two cohorts // separately, and so a human reading the ticket history isn't misled // by an "enriched" tag on a ticket that got no comment. Both labels // are excluded from the poll JQL so a labeled ticket never gets // re-classified — the whole point of this change is to stop paying AI // tokens on the same "not for us" tickets every hour. const SKIP_LABEL = 'bot-skipped'; 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; // Legacy map — COMPONENT_ROUTES is the component-based source of truth. // Enrichment execution uses jiraPoller/runEnrichment.js. // 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 }, }; // Component name → emoji for Webex summary bullets. Icons follow the // Jira component (not AI `kind`) so Mobility and Communication Services // stay visually distinct even though both enrich as phone snapshots. export { COMPONENT_ICONS, iconForTicket } from './jiraPoller/ticketIcons.js'; // 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 `!=` and `NOT IN` operators both exclude // issues where the field is empty (documented Atlassian behavior), and // brand-new tickets almost always have zero labels. A naive // `labels NOT IN (...)` therefore filters out precisely the tickets we // want. The `IS EMPTY OR ... NOT IN ...` union is the standard // workaround — it matches "no labels at all" plus "has labels, none of // which are our bot labels". Do NOT "simplify" this back to a bare // `NOT IN` or `!=`. 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 NOT IN ("${BOT_LABEL}", "${SKIP_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 }; // Apply the SKIP_LABEL to a ticket. Deliberately non-throwing — the // caller is inside the per-ticket loop and a labeling failure should // NOT abort the batch or bubble up. If Jira briefly rejects the label // call, the ticket re-enters the JQL next hour and gets one duplicate // AI classification, which is cheap. Dropping the poll entirely would // be far worse. async function tagSkipped(key) { try { await jira.addLabel(key, SKIP_LABEL); } catch (err) { logger( 'jira:poller', `${key}: failed to apply '${SKIP_LABEL}' — ticket will be re-classified next poll: ${err.message}`, 'warn', ); } } /** * 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, components } 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; } const plan = resolveEnrichmentPlan(ticketPayload, classification); if (plan.skip || !plan.storeNum || !plan.checks.length) { const reason = plan.skipReason || classification.reason; logger('jira:poller', `${key}: SKIP — ${reason}`); await tagSkipped(key); skipped.push({ key, reason }); continue; } try { logger( 'jira:poller', `${key}: enriching (store ${plan.storeNum}, checks=${plan.checks.join('+')}) — ` + `rules: ${plan.matchedRules.join(', ')} · AI: ${classification.reason}`, ); const { bodyMarkdown } = await runEnrichmentChecks(plan.storeNum, plan.checks); const rulesLabel = plan.matchedRules.length ? plan.matchedRules.join(', ') : classification.kind; const headerLine = `Auto-enriched by CollabFinder — store ${plan.storeNum} ` + `at ${new Date().toISOString()} · rules: ${rulesLabel} · AI: ${classification.reason}`; const adf = buildAdfComment({ headerLine, bodyNodes: markdownToAdfContent(bodyMarkdown), }); await jira.addComment(key, adf); await jira.addLabel(key, BOT_LABEL); enriched.push({ key, summary, storeNum: plan.storeNum, kind: classification.kind, reason: classification.reason, checks: plan.checks, matchedRules: plan.matchedRules, components: f.components || [], }); } 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) { if (!getJiraBrowseUrl(enriched[0]?.key)) { logger( 'jira:poller', 'JIRA_BASE_URL is unset — poller summary ticket keys will not be clickable', 'warn', ); } const lines = [ `**${enriched.length} new ticket${enriched.length === 1 ? '' : 's'} auto-enriched** (${elapsedSec}s, ${totalTokens} AI tokens)`, '', ...enriched.map((t) => formatEnrichedTicketLine(t)), ]; if (skipped.length > 0) { lines.push(''); lines.push( `_${skipped.length} ticket(s) skipped: ` + `${skipped.map((s) => formatSkippedTicketRef(s)).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 }; }