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