From 98dd33a20e101d1c05828fa27e9274f5589c6d6b Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Tue, 28 Jul 2026 12:26:28 -0400 Subject: [PATCH] Clean up Jira rich text in Webex approval card details. Convert wiki markup to readable text and render each detail field in its own card block. Co-authored-by: Cursor --- config/requestTypeFields.json | 3 + lib/jiraRichText.js | 101 ++++++++++++++++++++++++ lib/requestTypeFields.js | 6 +- services/approvalCards.js | 143 ++++++++++++++++++++++------------ test/jiraRichText.test.js | 79 +++++++++++++++++++ 5 files changed, 281 insertions(+), 51 deletions(-) create mode 100644 lib/jiraRichText.js create mode 100644 test/jiraRichText.test.js diff --git a/config/requestTypeFields.json b/config/requestTypeFields.json index fa5b000..ce87d91 100644 --- a/config/requestTypeFields.json +++ b/config/requestTypeFields.json @@ -55,6 +55,7 @@ { "fieldId": "customfield_10287", "label": "Change category", "format": "option" }, { "fieldId": "customfield_10259", "label": "Impact level", "format": "option" }, { "fieldId": "customfield_10257", "label": "Implementation date", "format": "datetime" }, + { "fieldId": "description", "label": "Implementation steps", "format": "textarea" }, { "fieldId": "customfield_10252", "label": "Business justification", "format": "textarea" }, { "fieldId": "customfield_10256", "label": "Business impact / risk", "format": "textarea" }, { "fieldId": "customfield_10253", "label": "Testing completed", "format": "textarea" }, @@ -89,6 +90,7 @@ "includeCommon": true, "displayFields": [ { "fieldId": "customfield_10257", "label": "Production deploy date", "format": "datetime" }, + { "fieldId": "description", "label": "Implementation steps", "format": "textarea" }, { "fieldId": "customfield_10252", "label": "Business justification", "format": "textarea" }, { "fieldId": "customfield_10256", "label": "Impacted services", "format": "textarea" }, { "fieldId": "customfield_10253", "label": "Testing completed", "format": "textarea" }, @@ -104,6 +106,7 @@ "serviceDeskId": "135", "includeCommon": true, "displayFields": [ + { "fieldId": "description", "label": "Implementation steps", "format": "textarea" }, { "fieldId": "customfield_10309", "label": "Firewall request details", "format": "textarea" }, { "fieldId": "customfield_10252", "label": "End result / justification", "format": "textarea" }, { "fieldId": "customfield_10242", "label": "Application(s)", "format": "cmdb" }, diff --git a/lib/jiraRichText.js b/lib/jiraRichText.js new file mode 100644 index 0000000..600f5c0 --- /dev/null +++ b/lib/jiraRichText.js @@ -0,0 +1,101 @@ +function adfNodeToText(node) { + if (!node) { + return ''; + } + + if (Array.isArray(node)) { + return node.map(adfNodeToText).join(''); + } + + if (typeof node === 'string') { + return node; + } + + switch (node.type) { + case 'text': + return node.text || ''; + case 'hardBreak': + return '\n'; + case 'paragraph': + case 'heading': + return `${(node.content || []).map(adfNodeToText).join('')}\n`; + case 'bulletList': + case 'orderedList': + return (node.content || []) + .map(item => adfNodeToText(item)) + .join(''); + case 'listItem': + return `• ${(node.content || []).map(adfNodeToText).join('').trim()}\n`; + case 'rule': + return '\n'; + case 'codeBlock': + return `${(node.content || []).map(adfNodeToText).join('')}\n`; + case 'inlineCard': + case 'blockCard': + return node.attrs?.url || ''; + default: + if (node.content) { + return node.content.map(adfNodeToText).join(''); + } + return ''; + } +} + +export function adfToPlainText(value) { + if (!value || typeof value !== 'object') { + return ''; + } + if (value.type !== 'doc' || !Array.isArray(value.content)) { + return ''; + } + return value.content.map(adfNodeToText).join('').trim(); +} + +export function formatJiraWikiMarkup(text) { + if (text == null || text === '') { + return ''; + } + + let output = String(text).replace(/\r\n/g, '\n'); + + output = output.replace(/\{noformat\}([\s\S]*?)\{noformat\}/gi, (_, body) => `\n${body.trim()}\n`); + output = output.replace(/\{code(?::[^}]*)?\}([\s\S]*?)\{code\}/gi, (_, body) => `\n${body.trim()}\n`); + output = output.replace(/\{color(?::[^}]*)?\}([\s\S]*?)\{color\}/gi, '$1'); + output = output.replace(/\{quote\}([\s\S]*?)\{quote\}/gi, '$1'); + output = output.replace(/![^!\n]+!/g, ''); + output = output.replace(/\[~accountid:[^\]]+\]/gi, ''); + output = output.replace(/\[([^\]|]+)\|([^\]]+?)(?:\|smart-link)?\]/gi, (_, label, url) => { + const cleanLabel = label.trim(); + const cleanUrl = url.trim(); + if (!cleanLabel || cleanLabel === cleanUrl) { + return cleanUrl; + } + return `${cleanLabel} (${cleanUrl})`; + }); + output = output.replace(/\*\*([^*\n]+?)\*\*/g, '$1'); + output = output.replace(/(? `${title.trim()}\n`); + output = output.replace(/^#{1,6}\s+(.+)$/gm, '$1\n'); + output = output.replace(/^\s*[-*#]\s+/gm, '• '); + output = output.replace(/^-{4,}\s*$/gm, '\n'); + output = output.replace(/[ \t]+\n/g, '\n'); + output = output.replace(/\n{3,}/g, '\n\n'); + + return output.trim(); +} + +export function formatJiraRichText(value) { + if (value == null || value === '') { + return ''; + } + + if (typeof value === 'object') { + const adfText = adfToPlainText(value); + if (adfText) { + return formatJiraWikiMarkup(adfText); + } + return value.displayName || value.value || value.name || ''; + } + + return formatJiraWikiMarkup(value); +} diff --git a/lib/requestTypeFields.js b/lib/requestTypeFields.js index 694af13..025582c 100644 --- a/lib/requestTypeFields.js +++ b/lib/requestTypeFields.js @@ -1,4 +1,5 @@ import fs from 'fs'; +import { formatJiraRichText } from './jiraRichText.js'; const SKIP_FIELD_IDS = new Set(['attachment']); @@ -89,9 +90,10 @@ export function formatFieldValue(value, format) { case 'text': default: if (typeof value === 'object') { - return value.displayName || value.value || value.name || JSON.stringify(value); + const richText = formatJiraRichText(value); + return richText || value.displayName || value.value || value.name || null; } - return String(value); + return formatJiraRichText(value) || null; } } diff --git a/services/approvalCards.js b/services/approvalCards.js index 7436c3d..e3b4505 100644 --- a/services/approvalCards.js +++ b/services/approvalCards.js @@ -2,13 +2,6 @@ import { buildDetailLinesForRequestType } from '../lib/requestTypeFields.js'; const PORTAL_BASE_URL = 'https://aeo.atlassian.net/servicedesk/customer/portal/135'; -function appendDetailLines(detailText, lines) { - for (const line of lines) { - detailText += ` - **${line.label}:** ${line.value}\n`; - } - return detailText; -} - function getRequestTypeId(jiraTicket) { return jiraTicket.issue.fields?.customfield_10010?.requestType?.id; } @@ -78,19 +71,84 @@ function buildPlainTextHeader(jiraTicket) { return text; } -function buildCardPayload({ jiraTicket, detailText, assignmentText = '' }) { - const body = [...buildHeaderBlocks(jiraTicket)]; +function buildDetailBlocks(detailLines) { + if (!detailLines.length) { + return []; + } - if (detailText.trim()) { - body.push({ + const blocks = [ + { type: 'TextBlock', - wrap: true, - text: detailText.trim(), + text: 'Details', + weight: 'Bolder', + size: 'Medium', separator: true, spacing: 'Medium', + }, + ]; + + for (const line of detailLines) { + blocks.push({ + type: 'TextBlock', + text: line.label, + weight: 'Bolder', + spacing: 'Medium', + wrap: true, + }); + blocks.push({ + type: 'TextBlock', + text: line.value, + spacing: 'Small', + wrap: true, }); } + return blocks; +} + +function formatDetailLinesPlain(detailLines) { + let text = '**Details:**\n'; + for (const line of detailLines) { + text += `\n**${line.label}:**\n${line.value}\n`; + } + return text; +} + +function buildFallbackChangeDetailLines(jiraTicket) { + const lines = []; + const fields = jiraTicket.issue.fields; + + if (fields.customfield_10080) { + lines.push({ label: 'Risk', value: fields.customfield_10080.value }); + } + if (fields.customfield_10004) { + lines.push({ label: 'Impact', value: fields.customfield_10004.value }); + } + if (fields.customfield_10256) { + lines.push({ label: 'Business impact', value: fields.customfield_10256 }); + } + if (fields.customfield_10252) { + lines.push({ label: 'Business justification', value: fields.customfield_10252 }); + } + if (fields.customfield_10257) { + const changeDate = new Date(fields.customfield_10257).toLocaleDateString(); + const changeTime = new Date(fields.customfield_10257).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); + lines.push({ label: 'Implementation date', value: `${changeDate} ${changeTime}` }); + } + + return lines; +} + +function buildCardPayload({ jiraTicket, detailLines = [], assignmentText = '' }) { + const body = [ + ...buildHeaderBlocks(jiraTicket), + ...buildDetailBlocks(detailLines), + ]; + if (assignmentText.trim()) { body.push({ type: 'TextBlock', @@ -122,8 +180,8 @@ function buildCardPayload({ jiraTicket, detailText, assignmentText = '' }) { }; let requestText = buildPlainTextHeader(jiraTicket); - if (detailText.trim()) { - requestText += `---\n${detailText}`; + if (detailLines.length) { + requestText += `---\n${formatDetailLinesPlain(detailLines)}`; } if (assignmentText.trim()) { requestText += `---\n${assignmentText}`; @@ -134,49 +192,36 @@ function buildCardPayload({ jiraTicket, detailText, assignmentText = '' }) { export function buildChangeApproval(jiraTicket, log) { return new Promise(function (resolve) { - let detailText = '**Details:**\n'; - if (jiraTicket.issue.fields.description) { - detailText += `**Description:** ${jiraTicket.issue.fields.description}\n`; - } - const requestTypeId = getRequestTypeId(jiraTicket); - if (requestTypeId) { - detailText = appendDetailLines(detailText, buildDetailLinesForRequestType(jiraTicket, requestTypeId)); - } else { - if (jiraTicket.issue.fields.customfield_10080) { detailText += ` - **Risk:** ${jiraTicket.issue.fields.customfield_10080.value}\n`; } - if (jiraTicket.issue.fields.customfield_10004) { detailText += ` - **Impact:** ${jiraTicket.issue.fields.customfield_10004.value}\n`; } - if (jiraTicket.issue.fields.customfield_10256) { detailText += ` - **Business Impact:** ${jiraTicket.issue.fields.customfield_10256}\n`; } - if (jiraTicket.issue.fields.customfield_10252) { detailText += ` - **Business Justification:** ${jiraTicket.issue.fields.customfield_10252}\n`; } - if (jiraTicket.issue.fields.customfield_10257) { - const changeDate = new Date(jiraTicket.issue.fields.customfield_10257).toLocaleDateString(); - const changeTime = new Date(jiraTicket.issue.fields.customfield_10257).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true }); - detailText += ` - **Implementation Date:** ${changeDate} ${changeTime}\n`; - } - } + const detailLines = requestTypeId + ? buildDetailLinesForRequestType(jiraTicket, requestTypeId) + : buildFallbackChangeDetailLines(jiraTicket); - const hasDetails = detailText.replace('**Details:**\n', '').trim().length > 0; log.logDebug('buildChangeApproval', `Built card for ${jiraTicket.issue.key}`); - resolve(buildCardPayload({ - jiraTicket, - detailText: hasDetails ? detailText : '', - })); + resolve(buildCardPayload({ jiraTicket, detailLines })); }); } export function buildRequestApproval(jiraTicket) { return new Promise(function (resolve) { - let detailText = '**Details:**\n'; const requestTypeId = getRequestTypeId(jiraTicket); - if (requestTypeId) { - detailText = appendDetailLines(detailText, buildDetailLinesForRequestType(jiraTicket, requestTypeId)); - } else if (jiraTicket.issue.fields.customfield_10314) { - detailText += ` - **Person:** ${jiraTicket.issue.fields.customfield_10346}, ${jiraTicket.issue.fields.customfield_10314}`; - if (jiraTicket.issue.fields.customfield_10310) { detailText += ` (${jiraTicket.issue.fields.customfield_10310.value})`; } - detailText += '\n'; - if (jiraTicket.issue.fields.customfield_10318) { detailText += ` - **Position:** ${jiraTicket.issue.fields.customfield_10318}\n`; } - } + let detailLines = []; - const hasDetails = detailText.replace('**Details:**\n', '').trim().length > 0; + if (requestTypeId) { + detailLines = buildDetailLinesForRequestType(jiraTicket, requestTypeId); + } else if (jiraTicket.issue.fields.customfield_10314) { + let person = `${jiraTicket.issue.fields.customfield_10346}, ${jiraTicket.issue.fields.customfield_10314}`; + if (jiraTicket.issue.fields.customfield_10310) { + person += ` (${jiraTicket.issue.fields.customfield_10310.value})`; + } + detailLines.push({ label: 'Person', value: person }); + if (jiraTicket.issue.fields.customfield_10318) { + detailLines.push({ + label: 'Position', + value: jiraTicket.issue.fields.customfield_10318, + }); + } + } let assignmentText = ''; if (jiraTicket.issue.fields.reporter) { @@ -185,7 +230,7 @@ export function buildRequestApproval(jiraTicket) { resolve(buildCardPayload({ jiraTicket, - detailText: hasDetails ? detailText : '', + detailLines, assignmentText, })); }); diff --git a/test/jiraRichText.test.js b/test/jiraRichText.test.js new file mode 100644 index 0000000..683731a --- /dev/null +++ b/test/jiraRichText.test.js @@ -0,0 +1,79 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { adfToPlainText, formatJiraRichText, formatJiraWikiMarkup } from '../lib/jiraRichText.js'; + +describe('formatJiraWikiMarkup', () => { + it('converts Jira wiki links to readable text', () => { + const input = '[Repo|https://code.ae.com/projects/SCL/repos/web-apps/browse]'; + assert.equal( + formatJiraWikiMarkup(input), + 'Repo (https://code.ae.com/projects/SCL/repos/web-apps/browse)' + ); + }); + + it('converts duplicate-url smart links to a single URL', () => { + const input = '[https://aeo.atlassian.net/browse/CHANGE-1|https://aeo.atlassian.net/browse/CHANGE-1|smart-link]'; + assert.equal( + formatJiraWikiMarkup(input), + 'https://aeo.atlassian.net/browse/CHANGE-1' + ); + }); + + it('normalizes bold markers and bullets', () => { + const input = '* Expected behavior\n** Current behavior'; + assert.match(formatJiraWikiMarkup(input), /• Expected behavior/); + assert.match(formatJiraWikiMarkup(input), /Current behavior/); + assert.equal(formatJiraWikiMarkup('**Rollback** plan'), 'Rollback plan'); + }); +}); + +describe('adfToPlainText', () => { + it('extracts text from ADF documents', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Start approx 4am est' }], + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Deploy banner update' }], + }, + ], + }, + ], + }, + ], + }; + + assert.match(adfToPlainText(adf), /Start approx 4am est/); + assert.match(adfToPlainText(adf), /Deploy banner update/); + }); +}); + +describe('formatJiraRichText', () => { + it('formats wiki strings and ADF objects', () => { + assert.equal(formatJiraRichText('**Rollback** plan'), 'Rollback plan'); + assert.equal( + formatJiraRichText({ + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Ready for approval' }], + }, + ], + }), + 'Ready for approval' + ); + }); +});