Link Jira poller summary tickets to browse URLs in Webex.

Wrap enriched and skipped ticket keys in markdown links using JIRA_BASE_URL so keys are clickable in the hourly poller room summary.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-08-10 15:17:18 -04:00
parent 860615cb89
commit 9282e9cd0d
7 changed files with 214 additions and 33 deletions

View file

@ -16,6 +16,9 @@ JQL search → classifyTicket (AI) → resolveEnrichmentPlan (rules)
Idempotency uses Jira labels (`bot-enriched`, `bot-skipped`), not local Idempotency uses Jira labels (`bot-enriched`, `bot-skipped`), not local
state. Transient failures leave the ticket unlabeled for retry next hour. state. Transient failures leave the ticket unlabeled for retry next hour.
The Webex summary posts each enriched ticket key as a markdown link to
`JIRA_BASE_URL/browse/{key}` so keys are clickable in the space.
## Communication Services symptom rules ## Communication Services symptom rules
For tickets with component **Communication Services** and a resolvable store: For tickets with component **Communication Services** and a resolvable store:

View file

@ -0,0 +1,28 @@
// services/jiraPoller/formatSummary.js
//
// Pure formatters for the hourly poller Webex summary message.
import { formatJiraIssueMarkdownLink } from '../../utils/jiraBrowseUrl.js';
import { iconForTicket } from './ticketIcons.js';
/**
* @param {{ key: string, summary: string, storeNum: string, kind: string, reason: string, components?: Array<{name?: string}> }} ticket
* @returns {string}
*/
export function formatEnrichedTicketLine(ticket) {
const icon = iconForTicket(ticket.components, ticket.kind);
const keyLink = formatJiraIssueMarkdownLink(ticket.key);
return (
`${icon} ${keyLink} [store ${ticket.storeNum}] — ${ticket.summary}\n` +
` _AI: ${ticket.reason}_`
);
}
/**
* @param {{ key: string, reason: string }} skipped
* @returns {string}
*/
export function formatSkippedTicketRef(skipped) {
return `${formatJiraIssueMarkdownLink(skipped.key)} (${skipped.reason})`;
}

View file

@ -0,0 +1,30 @@
// services/jiraPoller/ticketIcons.js
//
// Component/kind → emoji mapping for poller Webex summary bullets.
export const COMPONENT_ICONS = {
'Mobility': '📱',
'Communication Services': '☎️',
'Audio Visual': '📺',
};
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] || '🎫';
}

View file

@ -48,6 +48,11 @@ import { adfToPlainText } from '../utils/adfToPlainText.js';
import { markdownToAdfContent } from '../utils/markdownToAdf.js'; import { markdownToAdfContent } from '../utils/markdownToAdf.js';
import { buildAdfComment } from '../utils/adfComment.js'; import { buildAdfComment } from '../utils/adfComment.js';
import { resolveEnrichmentPlan } from './jiraPoller/enrichmentRules.js'; import { resolveEnrichmentPlan } from './jiraPoller/enrichmentRules.js';
import {
formatEnrichedTicketLine,
formatSkippedTicketRef,
} from './jiraPoller/formatSummary.js';
import { getJiraBrowseUrl } from '../utils/jiraBrowseUrl.js';
import { runEnrichmentChecks } from './jiraPoller/runEnrichment.js'; import { runEnrichmentChecks } from './jiraPoller/runEnrichment.js';
const BOT_LABEL = 'bot-enriched'; const BOT_LABEL = 'bot-enriched';
@ -82,34 +87,7 @@ export const COMPONENT_ROUTES = {
// Component name → emoji for Webex summary bullets. Icons follow the // Component name → emoji for Webex summary bullets. Icons follow the
// Jira component (not AI `kind`) so Mobility and Communication Services // Jira component (not AI `kind`) so Mobility and Communication Services
// stay visually distinct even though both enrich as phone snapshots. // stay visually distinct even though both enrich as phone snapshots.
export const COMPONENT_ICONS = { export { COMPONENT_ICONS, iconForTicket } from './jiraPoller/ticketIcons.js';
'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 // 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 // and easy to audit against the spec. Any status/component change lives
@ -388,19 +366,24 @@ export async function pollNewTickets({ prime = false } = {}) {
// (this is the "compensating control" for full-auto mode). // (this is the "compensating control" for full-auto mode).
const roomId = process.env.JIRA_POLLER_ROOM_ID; const roomId = process.env.JIRA_POLLER_ROOM_ID;
if (enriched.length > 0 && roomId) { if (enriched.length > 0 && roomId) {
if (!getJiraBrowseUrl(enriched[0]?.key)) {
logger(
'jira:poller',
'JIRA_BASE_URL is unset — poller summary ticket keys will not be clickable',
'warn',
);
}
const lines = [ const lines = [
`**${enriched.length} new ticket${enriched.length === 1 ? '' : 's'} auto-enriched** (${elapsedSec}s, ${totalTokens} AI tokens)`, `**${enriched.length} new ticket${enriched.length === 1 ? '' : 's'} auto-enriched** (${elapsedSec}s, ${totalTokens} AI tokens)`,
'', '',
...enriched.map((t) => ...enriched.map((t) => formatEnrichedTicketLine(t)),
`${iconForTicket(t.components, t.kind)} **${t.key}** [store ${t.storeNum}] — ${t.summary}\n` +
` _AI: ${t.reason}_`,
),
]; ];
if (skipped.length > 0) { if (skipped.length > 0) {
lines.push(''); lines.push('');
lines.push( lines.push(
`_${skipped.length} ticket(s) skipped: ` + `_${skipped.length} ticket(s) skipped: ` +
`${skipped.map((s) => `${s.key} (${s.reason})`).join(', ')}_`, `${skipped.map((s) => formatSkippedTicketRef(s)).join(', ')}_`,
); );
} }
try { try {

View file

@ -0,0 +1,52 @@
// tests/jiraBrowseUrl.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getJiraBrowseUrl,
formatJiraIssueMarkdownLink,
} from '../utils/jiraBrowseUrl.js';
const ORIGINAL_BASE = process.env.JIRA_BASE_URL;
test.after(() => {
if (ORIGINAL_BASE === undefined) {
delete process.env.JIRA_BASE_URL;
} else {
process.env.JIRA_BASE_URL = ORIGINAL_BASE;
}
});
test('getJiraBrowseUrl builds browse URL from JIRA_BASE_URL', () => {
process.env.JIRA_BASE_URL = 'https://aeo.atlassian.net/';
assert.equal(
getJiraBrowseUrl('AV-123'),
'https://aeo.atlassian.net/browse/AV-123',
);
});
test('getJiraBrowseUrl returns null for invalid keys', () => {
process.env.JIRA_BASE_URL = 'https://aeo.atlassian.net';
assert.equal(getJiraBrowseUrl(''), null);
assert.equal(getJiraBrowseUrl('invalid'), null);
assert.equal(getJiraBrowseUrl('av-123'), null);
});
test('getJiraBrowseUrl returns null when JIRA_BASE_URL is unset', () => {
delete process.env.JIRA_BASE_URL;
assert.equal(getJiraBrowseUrl('AV-123'), null);
});
test('formatJiraIssueMarkdownLink returns markdown link when URL is available', () => {
process.env.JIRA_BASE_URL = 'https://aeo.atlassian.net';
assert.equal(
formatJiraIssueMarkdownLink('AV-123'),
'[AV-123](https://aeo.atlassian.net/browse/AV-123)',
);
});
test('formatJiraIssueMarkdownLink falls back to backticks when base URL missing', () => {
delete process.env.JIRA_BASE_URL;
assert.equal(formatJiraIssueMarkdownLink('AV-123'), '`AV-123`');
});

View file

@ -0,0 +1,51 @@
// tests/jiraPoller.formatSummary.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
formatEnrichedTicketLine,
formatSkippedTicketRef,
} from '../services/jiraPoller/formatSummary.js';
const ORIGINAL_BASE = process.env.JIRA_BASE_URL;
test.after(() => {
if (ORIGINAL_BASE === undefined) {
delete process.env.JIRA_BASE_URL;
} else {
process.env.JIRA_BASE_URL = ORIGINAL_BASE;
}
});
test('formatEnrichedTicketLine includes clickable Jira markdown link', () => {
process.env.JIRA_BASE_URL = 'https://aeo.atlassian.net';
const line = formatEnrichedTicketLine({
key: 'AV-4821',
summary: 'Store 0782 phone static on AA',
storeNum: '0782',
kind: 'phone',
reason: 'Communication Services symptom — call quality',
components: [{ name: 'Communication Services' }],
});
assert.match(line, /\[AV-4821\]\(https:\/\/aeo\.atlassian\.net\/browse\/AV-4821\)/);
assert.match(line, /\[store 0782\]/);
assert.match(line, /_AI: Communication Services symptom — call quality_/);
assert.match(line, /^• ☎️ /);
});
test('formatSkippedTicketRef includes clickable Jira markdown link', () => {
process.env.JIRA_BASE_URL = 'https://aeo.atlassian.net';
const ref = formatSkippedTicketRef({
key: 'AV-9999',
reason: 'no store number',
});
assert.equal(
ref,
'[AV-9999](https://aeo.atlassian.net/browse/AV-9999) (no store number)',
);
});

34
utils/jiraBrowseUrl.js Normal file
View file

@ -0,0 +1,34 @@
// utils/jiraBrowseUrl.js
//
// Build user-facing Jira browse URLs for Webex markdown links.
// API calls may use JIRA_CLOUD_ID; browse links always need JIRA_BASE_URL.
const ISSUE_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
/**
* @param {string} issueKey
* @returns {string|null}
*/
export function getJiraBrowseUrl(issueKey) {
const key = String(issueKey || '').trim();
if (!key || !ISSUE_KEY_RE.test(key)) return null;
const base = process.env.JIRA_BASE_URL?.replace(/\/$/, '');
if (!base) return null;
return `${base}/browse/${key}`;
}
/**
* @param {string} issueKey
* @returns {string}
*/
export function formatJiraIssueMarkdownLink(issueKey) {
const key = String(issueKey || '').trim();
if (!key) return '—';
const url = getJiraBrowseUrl(key);
if (url) return `[${key}](${url})`;
return `\`${key}\``;
}