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
|
# Mobility queue every hour, posts a phone or AV status snapshot as a
|
||||||
# Jira comment on each store-scoped ticket, labels the ticket
|
# Jira comment on each store-scoped ticket, labels the ticket
|
||||||
# `bot-enriched` so it's not re-processed, and posts a summary of newly
|
# `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
|
# Required scopes on JIRA_API_TOKEN: read + write on issues in the
|
||||||
# target projects (comment + edit-labels). The token owner needs "Add
|
# target projects (comment + edit-labels). The token owner needs "Add
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ const LONG_HELP = {
|
||||||
usage: ['/jirapoll', '/jirapoll prime'],
|
usage: ['/jirapoll', '/jirapoll prime'],
|
||||||
examples: ['/jirapoll', '/jirapoll prime'],
|
examples: ['/jirapoll', '/jirapoll prime'],
|
||||||
notes: [
|
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.',
|
'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.',
|
'`/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.',
|
'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
|
// `bot-enriched` so it's not re-processed on subsequent polls, and posts
|
||||||
// a summary of newly-enriched tickets to a configured Webex space.
|
// a summary of newly-enriched tickets to a configured Webex space.
|
||||||
//
|
//
|
||||||
// Idempotency model — a Jira label is the source of truth. The JQL
|
// Idempotency model — Jira labels are the source of truth. Two are
|
||||||
// includes `AND labels != bot-enriched`, so Jira itself only returns
|
// used: `bot-enriched` after a successful comment, `bot-skipped` after
|
||||||
// unseen tickets. This survives bot restarts, deploys, and (harmlessly)
|
// the AI classifier decides the ticket is out-of-scope (kind='skip',
|
||||||
// concurrent runs — no local state file, no in-memory cursor.
|
// 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
|
// Store number handling — the poller looks up the `Store Number` custom
|
||||||
// field id via `JiraClient.getFieldIdByName()` (cached for process
|
// field id via `JiraClient.getFieldIdByName()` (cached for process
|
||||||
|
|
@ -44,6 +51,15 @@ import { renderPhoneStatusMarkdown } from './renderers/phoneStatusRenderer.js';
|
||||||
import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.js';
|
import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.js';
|
||||||
|
|
||||||
const BOT_LABEL = 'bot-enriched';
|
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';
|
const STORE_FIELD_NAME = 'Store Number';
|
||||||
|
|
||||||
// Hard safety cap on tickets processed per poll. AI classification
|
// 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
|
// and easy to audit against the spec. Any status/component change lives
|
||||||
// here.
|
// here.
|
||||||
//
|
//
|
||||||
// Label clause gotcha: JQL's `!=` operator excludes issues where the
|
// Label clause gotcha: JQL's `!=` and `NOT IN` operators both exclude
|
||||||
// field is empty (documented Atlassian behavior), and brand-new tickets
|
// issues where the field is empty (documented Atlassian behavior), and
|
||||||
// almost always have zero labels. A naive `labels != bot-enriched`
|
// brand-new tickets almost always have zero labels. A naive
|
||||||
// therefore filters out precisely the tickets we want. The
|
// `labels NOT IN (...)` therefore filters out precisely the tickets we
|
||||||
// `IS EMPTY OR ... != ...` union is the standard workaround — it
|
// want. The `IS EMPTY OR ... NOT IN ...` union is the standard
|
||||||
// matches "no labels at all" plus "has labels, none of them are
|
// workaround — it matches "no labels at all" plus "has labels, none of
|
||||||
// bot-enriched". Do NOT "simplify" this back to a bare `!=`.
|
// which are our bot labels". Do NOT "simplify" this back to a bare
|
||||||
|
// `NOT IN` or `!=`.
|
||||||
export const POLLER_JQL = [
|
export const POLLER_JQL = [
|
||||||
'component IN ("Communication Services", "Audio Visual", Mobility)',
|
'component IN ("Communication Services", "Audio Visual", Mobility)',
|
||||||
'AND assignee = empty',
|
'AND assignee = empty',
|
||||||
'AND status IN ("Assign to Team", "Equipment Sent", Escalated, "High Severity Incident",',
|
'AND status IN ("Assign to Team", "Equipment Sent", Escalated, "High Severity Incident",',
|
||||||
' "In Progress", "New Request", "Not Started", Open, Pending, "Work in progress")',
|
' "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(' ');
|
].join(' ');
|
||||||
|
|
||||||
// Resolve the Store Number field id. Env override wins so an operator
|
// 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.
|
// isolation from the Jira / Webex clients this service imports.
|
||||||
export { buildAdfComment };
|
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,
|
* Poll Jira for unassigned tickets in the AV / Comm / Mobility queue,
|
||||||
* enrich store-scoped ones with a phone/av snapshot comment, label
|
* 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) {
|
if (classification.kind === 'skip' || !classification.storeNum) {
|
||||||
logger('jira:poller', `${key}: SKIP — AI: ${classification.reason}`);
|
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 });
|
skipped.push({ key, reason: classification.reason });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -282,8 +323,10 @@ export async function pollNewTickets({ prime = false } = {}) {
|
||||||
if (!collect) {
|
if (!collect) {
|
||||||
// Belt-and-suspenders: parseAndValidate already gates kind to
|
// Belt-and-suspenders: parseAndValidate already gates kind to
|
||||||
// phone|av|skip, but if the schema ever loosens we don't want 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');
|
logger('jira:poller', `${key}: SKIP — no collector for kind '${classification.kind}'`, 'warn');
|
||||||
|
await tagSkipped(key);
|
||||||
skipped.push({ key, reason: `unsupported kind: ${classification.kind}` });
|
skipped.push({ key, reason: `unsupported kind: ${classification.kind}` });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue