collabSupport/services/jiraPollerService.js
jmcqueen 9282e9cd0d Link Jira poller summary tickets to browse URLs in Webex.
Wrap enriched and skipped ticket keys in markdown links using JIRA_BASE_URL so keys are clickable in the hourly poller room summary.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:17:18 -04:00

397 lines
17 KiB
JavaScript

// 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 };
}