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:
parent
860615cb89
commit
9282e9cd0d
7 changed files with 214 additions and 33 deletions
|
|
@ -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:
|
||||
|
|
|
|||
28
services/jiraPoller/formatSummary.js
Normal file
28
services/jiraPoller/formatSummary.js
Normal 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})`;
|
||||
}
|
||||
30
services/jiraPoller/ticketIcons.js
Normal file
30
services/jiraPoller/ticketIcons.js
Normal 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] || '🎫';
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
52
tests/jiraBrowseUrl.test.js
Normal file
52
tests/jiraBrowseUrl.test.js
Normal 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`');
|
||||
});
|
||||
51
tests/jiraPoller.formatSummary.test.js
Normal file
51
tests/jiraPoller.formatSummary.test.js
Normal 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
34
utils/jiraBrowseUrl.js
Normal 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}\``;
|
||||
}
|
||||
Loading…
Reference in a new issue