Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules. Co-authored-by: Cursor <cursoragent@cursor.com>
414 lines
18 KiB
JavaScript
414 lines
18 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 /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 { resolveEnrichmentPlan } from './jiraPoller/enrichmentRules.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 const COMPONENT_ICONS = {
|
|
'Mobility': '📱',
|
|
'Communication Services': '☎️',
|
|
'Audio Visual': '📺',
|
|
};
|
|
|
|
// Fallbacks when a ticket has no recognized component (shouldn't
|
|
// happen given POLLER_JQL, but the AI can re-route kind independently).
|
|
const KIND_ICONS = {
|
|
phone: '☎️',
|
|
av: '📺',
|
|
};
|
|
|
|
/**
|
|
* Pick the summary-bullet icon for a ticket.
|
|
* Prefer the first recognized Jira component; fall back to AI kind.
|
|
*
|
|
* @param {Array<{name?: string}>|null|undefined} components
|
|
* @param {'phone'|'av'|string} [kind]
|
|
* @returns {string}
|
|
*/
|
|
export function iconForTicket(components, kind) {
|
|
for (const c of components || []) {
|
|
const icon = COMPONENT_ICONS[c?.name];
|
|
if (icon) return icon;
|
|
}
|
|
return KIND_ICONS[kind] || '🎫';
|
|
}
|
|
|
|
// 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) {
|
|
const lines = [
|
|
`**${enriched.length} new ticket${enriched.length === 1 ? '' : 's'} auto-enriched** (${elapsedSec}s, ${totalTokens} AI tokens)`,
|
|
'',
|
|
...enriched.map((t) =>
|
|
`• ${iconForTicket(t.components, t.kind)} **${t.key}** [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 };
|
|
}
|