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>
290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
// Jira issue lifecycle: fetch, search-by-reporter, status, update, transitions,
|
|
// close. All project-agnostic (works for any project the token can see) and
|
|
// uses the core /rest/api/3/issue/... endpoints.
|
|
import logger from '../../utilities/logger.js';
|
|
import config from '../../config/index.js';
|
|
import { jiraClient, plainTextToAdf } from './client.js';
|
|
import { fetchPublicComments } from './comments.js';
|
|
|
|
const KEY_RE = /^[A-Z]+-\d+$/;
|
|
|
|
export async function fetchJiraIssue(key) {
|
|
if (!key || !KEY_RE.test(key)) {
|
|
throw new Error(`Invalid ticket key: "${key}"`);
|
|
}
|
|
|
|
const url = `/rest/api/3/issue/${key}?fields=summary,status,assignee,created,updated,priority`;
|
|
try {
|
|
const response = await jiraClient.get(url);
|
|
return response.data;
|
|
} catch (error) {
|
|
logger.error('Fetch Jira issue failed:', error.response?.data || error.message);
|
|
throw new Error(`Issue fetch failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
export async function fetchPlainDescription(key) {
|
|
const payload = { expression: "issue.description.plainText", context: { issue: { key } } };
|
|
try {
|
|
const response = await jiraClient.post('/rest/api/3/expression/evaluate', payload, {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
return response.data.value || 'No description available.';
|
|
} catch (error) {
|
|
logger.warn('Failed to fetch plain description:', error.message);
|
|
return 'No description available.';
|
|
}
|
|
}
|
|
|
|
// Kept for future use (email → accountId lookup). Not currently called; the
|
|
// reporter search uses email directly in JQL.
|
|
// eslint-disable-next-line no-unused-vars
|
|
async function getAccountIdFromEmail(email) {
|
|
if (!email) {
|
|
throw new Error('Email is required');
|
|
}
|
|
|
|
const url = `/rest/api/3/user/search?query=${encodeURIComponent(email)}&maxResults=10`;
|
|
try {
|
|
const response = await jiraClient.get(url);
|
|
const users = response.data || [];
|
|
|
|
if (users.length === 0) {
|
|
throw new Error(`No users found matching "${email}"`);
|
|
}
|
|
|
|
let user = users.find(u => u.emailAddress?.toLowerCase() === email.toLowerCase());
|
|
if (!user && users.length > 0) {
|
|
user = users[0];
|
|
}
|
|
|
|
if (!user?.accountId) {
|
|
throw new Error(`No usable accountId found for "${email}"`);
|
|
}
|
|
|
|
logger.info(`Using accountId ${user.accountId} for email "${email}"`);
|
|
return user.accountId;
|
|
} catch (err) {
|
|
throw new Error(`User lookup failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search open tickets reported by a given email (across CS/SS/SUPPORT
|
|
* projects). Enriches each result with plain-text description + last 6
|
|
* public comments so downstream (Grok) has full context in one round trip.
|
|
*/
|
|
export async function searchOpenTicketsByReporterEmail(email) {
|
|
if (!email || typeof email !== 'string' || email.trim() === '') {
|
|
throw new Error("Email is required");
|
|
}
|
|
|
|
const jql = `project in (CS, SS, SUPPORT)
|
|
AND reporter = "${email}"
|
|
AND statusCategory != Done
|
|
ORDER BY updated DESC`;
|
|
|
|
try {
|
|
const response = await jiraClient.post('/rest/api/3/search/jql', {
|
|
jql: jql,
|
|
maxResults: 8,
|
|
fields: ["key", "summary", "status", "updated", "description"],
|
|
expand: "comments"
|
|
}, {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
const issues = response.data.issues || [];
|
|
|
|
const enrichedIssues = await Promise.all(
|
|
issues.map(async (issue) => {
|
|
const key = issue.key;
|
|
try {
|
|
const [plainDesc, publicComments] = await Promise.all([
|
|
fetchPlainDescription(key).catch(() => "No description available."),
|
|
fetchPublicComments(key).catch(() => [])
|
|
]);
|
|
|
|
issue.enrichedNotes = {
|
|
description: plainDesc,
|
|
publicComments: publicComments.slice(-6)
|
|
};
|
|
} catch (err) {
|
|
logger.warn(`Failed to enrich notes for ${key}:`, err.message);
|
|
issue.enrichedNotes = { description: "Notes unavailable.", publicComments: [] };
|
|
}
|
|
return issue;
|
|
})
|
|
);
|
|
|
|
return enrichedIssues;
|
|
} catch (error) {
|
|
logger.error('Jira reporter search failed:', error.response?.data || error.message);
|
|
throw new Error(`Failed to search tickets reported by ${email}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compact, purpose-built status view — no Grok, no comment enrichment.
|
|
* Use this when a caller just wants "where is this ticket right now?".
|
|
*/
|
|
export async function getTicketStatus(key) {
|
|
if (!key || !KEY_RE.test(key)) {
|
|
throw new Error(`Invalid ticket key: "${key}"`);
|
|
}
|
|
|
|
const url = `/rest/api/3/issue/${key}?fields=summary,status,assignee,reporter,priority,resolution,created,updated,labels`;
|
|
try {
|
|
const { data } = await jiraClient.get(url);
|
|
const f = data.fields || {};
|
|
return {
|
|
key: data.key,
|
|
summary: f.summary || null,
|
|
status: f.status?.name || null,
|
|
statusCategory: f.status?.statusCategory?.key || null,
|
|
assignee: f.assignee?.displayName || f.assignee?.emailAddress || null,
|
|
reporter: f.reporter?.displayName || f.reporter?.emailAddress || null,
|
|
priority: f.priority?.name || null,
|
|
resolution: f.resolution?.name || null,
|
|
labels: f.labels || [],
|
|
created: f.created || null,
|
|
updated: f.updated || null
|
|
};
|
|
} catch (err) {
|
|
logger.error('getTicketStatus failed', { key, status: err.response?.status, details: err.response?.data });
|
|
const e = new Error(`Failed to fetch status for ${key}: ${err.message}`);
|
|
e.status = err.response?.status;
|
|
e.details = err.response?.data;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Partial issue update. Accepts flat, friendly fields:
|
|
* { summary, description, priority, labels, assigneeAccountId, additional }
|
|
* `additional` is merged raw into the `fields` object (e.g. customfield_*).
|
|
* `description` is a plain string; converted to ADF here.
|
|
*/
|
|
export async function updateTicket(key, updates = {}) {
|
|
if (!key || !KEY_RE.test(key)) {
|
|
throw new Error(`Invalid ticket key: "${key}"`);
|
|
}
|
|
|
|
const fields = {};
|
|
if (updates.summary !== undefined) fields.summary = String(updates.summary);
|
|
if (updates.description !== undefined) fields.description = plainTextToAdf(updates.description);
|
|
if (updates.priority) fields.priority = { name: String(updates.priority) };
|
|
if (Array.isArray(updates.labels)) fields.labels = updates.labels.map(String);
|
|
if (updates.assigneeAccountId) fields.assignee = { accountId: String(updates.assigneeAccountId) };
|
|
if (updates.additional && typeof updates.additional === 'object') Object.assign(fields, updates.additional);
|
|
|
|
if (Object.keys(fields).length === 0) {
|
|
throw new Error('updateTicket called with no updatable fields');
|
|
}
|
|
|
|
try {
|
|
await jiraClient.put(`/rest/api/3/issue/${key}`, { fields }, {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
logger.info('Ticket updated', { key, fieldKeys: Object.keys(fields) });
|
|
return { key, updated: Object.keys(fields) };
|
|
} catch (err) {
|
|
logger.error('updateTicket failed', { key, status: err.response?.status, details: err.response?.data });
|
|
const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message);
|
|
e.status = err.response?.status;
|
|
e.details = err.response?.data;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch available workflow transitions for an issue. Useful for both the
|
|
* client picking a transition manually and for closeTicket() below.
|
|
*/
|
|
export async function getTransitions(key) {
|
|
if (!key || !KEY_RE.test(key)) {
|
|
throw new Error(`Invalid ticket key: "${key}"`);
|
|
}
|
|
try {
|
|
const { data } = await jiraClient.get(`/rest/api/3/issue/${key}/transitions`);
|
|
return (data.transitions || []).map(t => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
to: { id: t.to?.id, name: t.to?.name, statusCategory: t.to?.statusCategory?.key },
|
|
hasScreen: !!t.hasScreen
|
|
}));
|
|
} catch (err) {
|
|
logger.error('getTransitions failed', { key, status: err.response?.status, details: err.response?.data });
|
|
throw new Error(`Failed to fetch transitions for ${key}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a specific transition. Optionally set a resolution and/or append a
|
|
* comment in the same call (both are fields the transition screen can accept).
|
|
*/
|
|
export async function transitionTicket(key, transitionId, { resolution, comment, internal = false, additionalFields } = {}) {
|
|
if (!key || !KEY_RE.test(key)) {
|
|
throw new Error(`Invalid ticket key: "${key}"`);
|
|
}
|
|
if (!transitionId) throw new Error('transitionId is required');
|
|
|
|
const payload = { transition: { id: String(transitionId) } };
|
|
|
|
const fields = { ...(additionalFields || {}) };
|
|
if (resolution) fields.resolution = { name: String(resolution) };
|
|
if (Object.keys(fields).length) payload.fields = fields;
|
|
|
|
if (comment) {
|
|
const commentEntry = { add: { body: plainTextToAdf(String(comment)) } };
|
|
if (internal) {
|
|
commentEntry.add.visibility = { type: 'role', value: config.jira.commentVisibilityRole || 'Service Desk Team' };
|
|
}
|
|
payload.update = { comment: [commentEntry] };
|
|
}
|
|
|
|
try {
|
|
await jiraClient.post(`/rest/api/3/issue/${key}/transitions`, payload, {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
logger.info('Ticket transitioned', { key, transitionId, resolution });
|
|
return { key, transitionId, resolution: resolution || null };
|
|
} catch (err) {
|
|
logger.error('transitionTicket failed', {
|
|
key, transitionId, status: err.response?.status, details: err.response?.data
|
|
});
|
|
const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message);
|
|
e.status = err.response?.status;
|
|
e.details = err.response?.data;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience: find a "closing" transition and execute it.
|
|
* Prefers explicit `transitionName` if provided, otherwise picks the first
|
|
* transition whose target status is in category "done" (Jira's canonical
|
|
* category for closed/resolved/completed), falling back to a name-based match.
|
|
*/
|
|
export async function closeTicket(key, { transitionName, resolution = 'Done', comment, internal = false } = {}) {
|
|
const transitions = await getTransitions(key);
|
|
if (transitions.length === 0) {
|
|
throw new Error(`No workflow transitions available for ${key} (check assignee/permissions)`);
|
|
}
|
|
|
|
let chosen = null;
|
|
if (transitionName) {
|
|
chosen = transitions.find(t => t.name.toLowerCase() === transitionName.toLowerCase());
|
|
if (!chosen) {
|
|
throw new Error(`Transition "${transitionName}" not available for ${key}. Available: ${transitions.map(t => t.name).join(', ')}`);
|
|
}
|
|
} else {
|
|
chosen = transitions.find(t => t.to?.statusCategory === 'done')
|
|
|| transitions.find(t => /done|closed|resolved|complete/i.test(t.name));
|
|
if (!chosen) {
|
|
throw new Error(`Could not find a closing transition for ${key}. Available: ${transitions.map(t => t.name).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
return transitionTicket(key, chosen.id, { resolution, comment, internal });
|
|
}
|