Tag skipped Jira tickets with bot-skipped label
Prevents the hourly poller from re-classifying the same "not for us" tickets every hour and burning AI tokens on them forever. New SKIP_LABEL='bot-skipped' is applied whenever the AI classifier decides a ticket is out-of-scope (kind='skip', no store number, or an unroutable kind). The JQL now excludes both bot labels while preserving the `IS EMPTY OR` union so brand-new unlabeled tickets still match. Transient failures (AI down, collector 5xx, comment 5xx) intentionally stay unlabeled so they retry next poll. Labeling is wrapped in a non-throwing helper — a Jira 5xx on the label call can't abort the batch; the ticket just gets one duplicate classification next hour, which is far cheaper than dropping the poll.
This commit is contained in:
parent
351f89a9a4
commit
c15a471959
3 changed files with 61 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue