collabSupport/commands/jiraPoll.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

90 lines
3.7 KiB
JavaScript

// src/commands/jiraPoll.js
//
// /jirapoll — run the hourly Jira poller ON DEMAND. Same code
// path as the cron job. Enriches any unlabeled
// matching tickets with a phone/av snapshot comment,
// labels them, and posts the standard summary to
// JIRA_POLLER_ROOM_ID if any were enriched. Also
// replies in the invoking chat with the counts so
// you get immediate feedback.
//
// /jirapoll prime — run in PRIME mode (labels every matching ticket
// without enriching or notifying). Equivalent to a
// one-off JIRA_POLLER_PRIME_ON_START=true restart.
//
// Auth model
// The command is `mutating: true` in the registry, so hitting the
// HTTP path requires HTTP_API_TOKEN. From Webex chat any user who
// can talk to the bot can run it — same trust model as the other
// mutating chat commands (`offboarduser`, `webexhost`, etc.).
//
// Concurrency
// `pollNewTickets` uses Jira labels for idempotency, so overlapping
// invocations are safe (each ticket can only be enriched once). If
// two people trigger `/jirapoll` at the same second, they'll each
// process disjoint slices of the label-race — no double comments.
import { pollNewTickets } from '../services/jiraPollerService.js';
import { extractRequester, describeRequester } from '../utils/requester.js';
import { logger } from '../utils/logger.js';
export async function handleJiraPoll(bot, trigger) {
const requester = extractRequester(trigger);
const args = trigger.args || [];
const query = trigger.query || {};
const primeArg = (args[0] || query.mode || '').toLowerCase();
const isPrime = primeArg === 'prime';
logger('jira:poll:cmd', `Requested by ${describeRequester(requester)}${isPrime ? ' (PRIME mode)' : ''}`);
// Acknowledge immediately — a full poll can run 15-30s at N=7 or
// 60s+ at N=50, and the operator shouldn't stare at a blank chat.
const ackLines = [
isPrime
? '⏳ Running Jira poller in **PRIME mode** — will label matching tickets without enriching…'
: '⏳ Running Jira poller on demand — this may take up to a minute for a full 50-ticket batch…',
];
await bot.say('markdown', ackLines.join('\n'));
const startedAt = Date.now();
let result;
try {
result = await pollNewTickets({ prime: isPrime });
} catch (err) {
logger('jira:poll:cmd', `Poll failed: ${err.message}`, 'error');
await bot.say('markdown', `❌ Poll failed: \`${err.message}\``);
return;
}
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
if (isPrime) {
const primed = result.primed ?? 0;
const skipped = result.skipped ?? 0;
await bot.say(
'markdown',
`✅ **Prime pass complete** (${elapsedSec}s)\n` +
`Labeled **${primed}** ticket(s) as \`bot-enriched\` without enrichment.` +
(skipped ? ` ${skipped} labeling failure(s) — check logs.` : ''),
);
return;
}
const enriched = result.enriched ?? 0;
const skipped = result.skipped ?? 0;
const tokens = result.tokensUsed ?? 0;
const lines = [`✅ **Poll complete** (${elapsedSec}s, ${tokens} AI tokens)`];
lines.push(`Enriched: **${enriched}**, skipped: **${skipped}**`);
if (enriched > 0 && process.env.JIRA_POLLER_ROOM_ID) {
// Note the standard summary that already went to the configured
// room so the invoker knows where the per-ticket detail lives.
lines.push('');
lines.push(`_Per-ticket detail posted to the configured summary room._`);
} else if (enriched === 0) {
lines.push('');
lines.push('_Nothing to enrich right now._');
}
await bot.say('markdown', lines.join('\n'));
}