wxccai/src/services/jira/attachments.js
jmcqueen b8f2eab4ab Refactor #6: split jiraService.js into services/jira/{client,issues,comments,attachments,jsmRequests,assets}
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>
2026-07-01 16:36:21 -04:00

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;
}
}