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
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
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 { buildAdfComment } from '../utils/adfComment.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';
const BOT_LABEL = 'bot-enriched';
@ -82,34 +87,7 @@ export const COMPONENT_ROUTES = {
// Component name → emoji for Webex summary bullets. Icons follow the
// Jira component (not AI `kind`) so Mobility and Communication Services
// stay visually distinct even though both enrich as phone snapshots.
export const COMPONENT_ICONS = {
'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] || '🎫';
}
export { COMPONENT_ICONS, iconForTicket } from './jiraPoller/ticketIcons.js';
// 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
@ -388,19 +366,24 @@ export async function pollNewTickets({ prime = false } = {}) {
// (this is the "compensating control" for full-auto mode).
const roomId = process.env.JIRA_POLLER_ROOM_ID;
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 = [
`**${enriched.length} new ticket${enriched.length === 1 ? '' : 's'} auto-enriched** (${elapsedSec}s, ${totalTokens} AI tokens)`,
'',
...enriched.map((t) =>
`${iconForTicket(t.components, t.kind)} **${t.key}** [store ${t.storeNum}] — ${t.summary}\n` +
` _AI: ${t.reason}_`,
),
...enriched.map((t) => formatEnrichedTicketLine(t)),
];
if (skipped.length > 0) {
lines.push('');
lines.push(
`_${skipped.length} ticket(s) skipped: ` +
`${skipped.map((s) => `${s.key} (${s.reason})`).join(', ')}_`,
`${skipped.map((s) => formatSkippedTicketRef(s)).join(', ')}_`,
);
}
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}\``;
}