diff --git a/.env.example b/.env.example index 4da8e2d..423ff91 100644 --- a/.env.example +++ b/.env.example @@ -93,7 +93,10 @@ JIRA_MAX_RESULTS=30 # Mobility queue every hour, posts a phone or AV status snapshot as a # Jira comment on each store-scoped ticket, labels the ticket # `bot-enriched` so it's not re-processed, and posts a summary of newly -# enriched tickets to a Webex space. +# enriched tickets to a Webex space. Tickets the AI classifier decides +# are out-of-scope get labeled `bot-skipped` (same anti-retry purpose, +# no comment posted) so we don't burn AI tokens re-reading them every +# hour. # # Required scopes on JIRA_API_TOKEN: read + write on issues in the # target projects (comment + edit-labels). The token owner needs "Add diff --git a/commands/help.js b/commands/help.js index a8e7caa..f02a49d 100644 --- a/commands/help.js +++ b/commands/help.js @@ -134,7 +134,7 @@ const LONG_HELP = { usage: ['/jirapoll', '/jirapoll prime'], examples: ['/jirapoll', '/jirapoll prime'], notes: [ - 'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`.', + 'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.', 'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.', '`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.', 'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.', diff --git a/services/jiraPollerService.js b/services/jiraPollerService.js index 8b3ba2f..bec2ea6 100644 --- a/services/jiraPollerService.js +++ b/services/jiraPollerService.js @@ -8,10 +8,17 @@ // `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. +// 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 @@ -44,6 +51,15 @@ import { renderPhoneStatusMarkdown } from './renderers/phoneStatusRenderer.js'; import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.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 @@ -76,19 +92,20 @@ export const COMPONENT_ROUTES = { // 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 `!=`. +// 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 != "${BOT_LABEL}")`, + `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 @@ -139,6 +156,24 @@ export function extractStore(fieldValue) { // 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 @@ -274,6 +309,12 @@ export async function pollNewTickets({ prime = false } = {}) { if (classification.kind === 'skip' || !classification.storeNum) { logger('jira:poller', `${key}: SKIP — AI: ${classification.reason}`); + // Label AI-determined skips so we don't burn tokens re-classifying + // the same ticket every hour. If labeling fails (transient Jira + // 5xx, scope drop, whatever) we log and continue — the ticket will + // simply be re-classified next hour; the cost of one duplicate AI + // call is far cheaper than the cost of dropping a poll entirely. + await tagSkipped(key); skipped.push({ key, reason: classification.reason }); continue; } @@ -282,8 +323,10 @@ export async function pollNewTickets({ prime = false } = {}) { 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. + // silently no-op. Label as skipped for the same "don't retry" + // reasoning as the AI-skip branch above. logger('jira:poller', `${key}: SKIP — no collector for kind '${classification.kind}'`, 'warn'); + await tagSkipped(key); skipped.push({ key, reason: `unsupported kind: ${classification.kind}` }); continue; }