Pure move + one-way rewire, no logic changes. The old 1467-line
monolithic services/jiraService.js becomes a thin barrel that re-exports
the same public surface so both current consumers keep working unchanged:
- src/routes/wxccRoutes.js: `import * as jiraService`
- src/services/healthService.js: `import { jiraClient }`
New module layout (deps flow one-way, no cycles):
client.js — jiraClient, downloadClient, plainTextToAdf (foundational)
issues.js — fetch/search/status/update/transitions/close
comments.js — fetchPublicComments, addComment, postWebexSummaryComment
attachments.js — attachFileToJira, attachReadableTranscript
assets.js — AQL, resolveStoreAssetReference, probeAssetsForStore
jsmRequests.js — REQUEST_TYPE_MAP, createSSRequest, subtype helpers
Verified: barrel re-exports every original name (23 named + default with
same 17 members), REQUEST_TYPE_MAP still has 14 entries, jiraClient still
instantiates against the configured baseURL, and both consumers import
without errors under the real ES module loader.
New code should import from services/jira/* directly; the barrel is only
for backward compatibility with existing callers.
Co-authored-by: Cursor <cursoragent@cursor.com>
154 lines
5.7 KiB
JavaScript
154 lines
5.7 KiB
JavaScript
// Jira attachments + Webex CC transcript conversion.
|
|
// Downloads happen via downloadClient (own retry policy); uploads use jiraClient.
|
|
import FormData from 'form-data';
|
|
import logger from '../../utilities/logger.js';
|
|
import { jiraClient, downloadClient } from './client.js';
|
|
|
|
/**
|
|
* Shared helper: POST a Buffer as multipart attachment to the core Jira
|
|
* attachments endpoint.
|
|
* - Uses jiraClient (correct base + auth).
|
|
* - Spreads form.getHeaders() so boundary is set.
|
|
* - Cleans any charset from Content-Type (prevents 415).
|
|
* - Retry loop only around the API call (download is caller's job).
|
|
* - Fail-fast on 401/403 (scope/perms).
|
|
*/
|
|
async function attachBufferToJira(jiraKey, fileBuffer, fileName) {
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
try {
|
|
const form = new FormData();
|
|
form.append('file', fileBuffer, fileName);
|
|
|
|
const formHeaders = form.getHeaders();
|
|
if (formHeaders['content-type']) {
|
|
formHeaders['content-type'] = formHeaders['content-type'].replace(/;\s*charset=[^;]*/i, '');
|
|
}
|
|
|
|
const uploadPath = `/rest/api/3/issue/${jiraKey}/attachments`;
|
|
|
|
await jiraClient.post(uploadPath, form, {
|
|
headers: {
|
|
'X-Atlassian-Token': 'no-check',
|
|
...formHeaders
|
|
},
|
|
timeout: 15000
|
|
});
|
|
|
|
logger.info('File attached successfully', { jiraKey, fileName, attempt });
|
|
return;
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
logger.error('Jira file attach attempt failed', {
|
|
jiraKey,
|
|
fileName,
|
|
attempt,
|
|
status,
|
|
responseData: err.response?.data,
|
|
responseHeaders: err.response?.headers ? Object.fromEntries(
|
|
Object.entries(err.response.headers).filter(([k]) => !k.toLowerCase().includes('auth'))
|
|
) : undefined
|
|
});
|
|
if (status === 401 || status === 403) {
|
|
throw err;
|
|
}
|
|
if (attempt === 3) throw err;
|
|
await new Promise(r => setTimeout(r, attempt * 1500));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download a file from the given URL (S3 pre-signed) *once* and attach using
|
|
* the core Jira attachments API. Download happens outside the retry loop
|
|
* because the signed URL expires (~1800s).
|
|
*/
|
|
export async function attachFileToJira(jiraKey, fileUrl, fileName) {
|
|
let fileBuffer;
|
|
try {
|
|
const dl = await downloadClient.get(fileUrl, {
|
|
responseType: 'arraybuffer'
|
|
});
|
|
fileBuffer = Buffer.from(dl.data);
|
|
} catch (dlErr) {
|
|
logger.error('Failed to download file from S3 for attachment (URL likely expired on replay)', {
|
|
jiraKey,
|
|
fileName,
|
|
url: fileUrl,
|
|
status: dlErr.response?.status,
|
|
message: dlErr.message
|
|
});
|
|
throw dlErr;
|
|
}
|
|
|
|
await attachBufferToJira(jiraKey, fileBuffer, fileName);
|
|
}
|
|
|
|
/**
|
|
* Convert a Webex-style JSON transcript to human-readable text.
|
|
* Returns null if the shape isn't recognized.
|
|
*/
|
|
function formatTranscriptToHumanReadable(data) {
|
|
if (!data || !Array.isArray(data.responseContents)) return null;
|
|
const lines = [];
|
|
lines.push(`Transcript`);
|
|
if (data.interactionId) lines.push(`Interaction ID: ${data.interactionId}`);
|
|
if (data.languageCode) lines.push(`Language: ${data.languageCode}`);
|
|
lines.push('');
|
|
for (const entry of data.responseContents) {
|
|
const res = entry.recognitionResult;
|
|
if (!res || !res.alternatives || !res.alternatives[0]) continue;
|
|
const role = (res.role || 'UNKNOWN').toUpperCase();
|
|
const alt = res.alternatives[0];
|
|
const transcript = (alt.transcript || '').trim();
|
|
if (!transcript) continue;
|
|
let ts = '';
|
|
const words = alt.words || [];
|
|
if (words.length > 0) {
|
|
const start = words[0].start_time || {};
|
|
const totalSec = (start.seconds || 0) + Math.floor((start.nanos || 0) / 1e9);
|
|
const min = Math.floor(totalSec / 60);
|
|
const sec = Math.floor(totalSec % 60);
|
|
ts = `[${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}] `;
|
|
}
|
|
lines.push(`${ts}${role}: ${transcript}`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
async function fetchAndConvertTranscript(url) {
|
|
try {
|
|
const resp = await downloadClient.get(url, { timeout: 10000 });
|
|
return formatTranscriptToHumanReadable(resp.data);
|
|
} catch (e) {
|
|
logger.warn(`Failed to fetch/convert transcript: ${e.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download the JSON transcript, convert to human-readable text, and attach as
|
|
* `<base>-readable.txt`. Failures are swallowed so they don't mark the
|
|
* original JSON attach as failed.
|
|
*/
|
|
export async function attachReadableTranscript(jiraKey, transcriptUrl, originalFileName = null) {
|
|
const readable = await fetchAndConvertTranscript(transcriptUrl);
|
|
if (!readable) {
|
|
logger.warn('Readable transcript conversion yielded no content (check transcript JSON shape or URL)', { jiraKey });
|
|
return false;
|
|
}
|
|
const base = (originalFileName || `transcript-${jiraKey}`).replace(/\.json$/i, '');
|
|
const fileName = `${base}-readable.txt`;
|
|
|
|
try {
|
|
await attachBufferToJira(jiraKey, Buffer.from(readable, 'utf8'), fileName);
|
|
return true;
|
|
} catch (err) {
|
|
logger.error('Readable transcript attach failed (non-fatal)', {
|
|
jiraKey,
|
|
fileName,
|
|
error: err.response?.data?.message || err.message,
|
|
status: err.response?.status
|
|
});
|
|
return false;
|
|
}
|
|
}
|