From 9282e9cd0dccfb3867dd307bbbc7b73aa5cd3429 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Mon, 10 Aug 2026 15:17:18 -0400 Subject: [PATCH] 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 --- services/jiraPoller/README.md | 3 ++ services/jiraPoller/formatSummary.js | 28 ++++++++++++++ services/jiraPoller/ticketIcons.js | 30 +++++++++++++++ services/jiraPollerService.js | 49 ++++++++---------------- tests/jiraBrowseUrl.test.js | 52 ++++++++++++++++++++++++++ tests/jiraPoller.formatSummary.test.js | 51 +++++++++++++++++++++++++ utils/jiraBrowseUrl.js | 34 +++++++++++++++++ 7 files changed, 214 insertions(+), 33 deletions(-) create mode 100644 services/jiraPoller/formatSummary.js create mode 100644 services/jiraPoller/ticketIcons.js create mode 100644 tests/jiraBrowseUrl.test.js create mode 100644 tests/jiraPoller.formatSummary.test.js create mode 100644 utils/jiraBrowseUrl.js diff --git a/services/jiraPoller/README.md b/services/jiraPoller/README.md index cee3293..7dd6d76 100644 --- a/services/jiraPoller/README.md +++ b/services/jiraPoller/README.md @@ -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: diff --git a/services/jiraPoller/formatSummary.js b/services/jiraPoller/formatSummary.js new file mode 100644 index 0000000..358b334 --- /dev/null +++ b/services/jiraPoller/formatSummary.js @@ -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})`; +} diff --git a/services/jiraPoller/ticketIcons.js b/services/jiraPoller/ticketIcons.js new file mode 100644 index 0000000..fd7ea88 --- /dev/null +++ b/services/jiraPoller/ticketIcons.js @@ -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] || '🎫'; +} diff --git a/services/jiraPollerService.js b/services/jiraPollerService.js index 036f449..4e17985 100644 --- a/services/jiraPollerService.js +++ b/services/jiraPollerService.js @@ -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 { diff --git a/tests/jiraBrowseUrl.test.js b/tests/jiraBrowseUrl.test.js new file mode 100644 index 0000000..6d0ba68 --- /dev/null +++ b/tests/jiraBrowseUrl.test.js @@ -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`'); +}); diff --git a/tests/jiraPoller.formatSummary.test.js b/tests/jiraPoller.formatSummary.test.js new file mode 100644 index 0000000..92da075 --- /dev/null +++ b/tests/jiraPoller.formatSummary.test.js @@ -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)', + ); +}); diff --git a/utils/jiraBrowseUrl.js b/utils/jiraBrowseUrl.js new file mode 100644 index 0000000..245e4c6 --- /dev/null +++ b/utils/jiraBrowseUrl.js @@ -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}\``; +}