Skip to main content
Glama
issues.ts16.1 kB
/** * Jira Issues API endpoints. * @module jira/endpoints/issues */ import { getClient } from '../client.js'; import type { JiraIssue, JiraTransition, JiraUser, CreateIssueInput, UpdateIssueInput, JqlSearchParams, JqlSearchResponse, } from '../types.js'; import { createLogger } from '../../utils/logger.js'; import { resolveUserIdentifier } from './users.js'; import { markdownToAdf } from '../../utils/adf.js'; const logger = createLogger('jira-issues'); /** * Gets an issue by key or ID. * * @param issueIdOrKey - The issue key (e.g., "PROJ-123") or ID * @param fields - Optional list of fields to return * @param expand - Optional list of expansions * @returns The issue data * * @example * const issue = await getIssue('PROJ-123', ['summary', 'status']); */ export async function getIssue( issueIdOrKey: string, fields?: string[], expand?: string[] ): Promise<JiraIssue> { logger.debug('Getting issue', { issueIdOrKey }); const params: Record<string, string | undefined> = {}; if (fields?.length) { params['fields'] = fields.join(','); } if (expand?.length) { params['expand'] = expand.join(','); } return getClient().get<JiraIssue>(`/rest/api/3/issue/${issueIdOrKey}`, { params, }); } /** * Creates a new issue. * * @param input - Issue creation parameters * @returns The created issue * * @example * const issue = await createIssue({ * projectKey: 'PROJ', * summary: 'New feature request', * issueType: 'Story', * description: 'Detailed description...' * }); */ export async function createIssue(input: CreateIssueInput): Promise<JiraIssue> { logger.debug('Creating issue', { projectKey: input.projectKey }); const body: Record<string, unknown> = { fields: { project: { key: input.projectKey }, summary: input.summary, issuetype: { name: input.issueType }, }, }; const fields = body['fields'] as Record<string, unknown>; if (input.description) { // Convert markdown to ADF for proper formatting fields['description'] = markdownToAdf(input.description); } if (input.priority) { fields['priority'] = { name: input.priority }; } if (input.assignee) { // Resolve assignee by email, name, or account ID const accountId = await resolveUserIdentifier(input.assignee); fields['assignee'] = { accountId }; } if (input.labels?.length) { fields['labels'] = input.labels; } if (input.components?.length) { fields['components'] = input.components.map((name) => ({ name })); } if (input.parentKey) { fields['parent'] = { key: input.parentKey }; } if (input.customFields) { Object.assign(fields, input.customFields); } const response = await getClient().post<{ id: string; key: string; self: string; }>('/rest/api/3/issue', { body }); // Fetch the full issue data return getIssue(response.key); } /** * Updates an existing issue. * * @param issueIdOrKey - The issue key or ID * @param input - Fields to update * * @example * await updateIssue('PROJ-123', { * summary: 'Updated summary', * priority: 'High' * }); */ export async function updateIssue( issueIdOrKey: string, input: UpdateIssueInput ): Promise<void> { logger.debug('Updating issue', { issueIdOrKey }); const fields: Record<string, unknown> = {}; if (input.summary !== undefined) { fields['summary'] = input.summary; } if (input.description !== undefined) { // Convert markdown to ADF for proper formatting fields['description'] = input.description ? markdownToAdf(input.description) : null; } if (input.priority !== undefined) { fields['priority'] = { name: input.priority }; } if (input.assignee !== undefined) { if (input.assignee) { // Resolve assignee by email, name, or account ID const accountId = await resolveUserIdentifier(input.assignee); fields['assignee'] = { accountId }; } else { fields['assignee'] = null; } } if (input.labels !== undefined) { fields['labels'] = input.labels; } if (input.components !== undefined) { fields['components'] = input.components.map((name) => ({ name })); } if (input.customFields) { Object.assign(fields, input.customFields); } await getClient().put(`/rest/api/3/issue/${issueIdOrKey}`, { body: { fields }, }); } /** * Deletes an issue. * * @param issueIdOrKey - The issue key or ID * @param deleteSubtasks - Whether to delete subtasks * * @example * await deleteIssue('PROJ-123', true); */ export async function deleteIssue( issueIdOrKey: string, deleteSubtasks: boolean = false ): Promise<void> { logger.debug('Deleting issue', { issueIdOrKey, deleteSubtasks }); await getClient().delete(`/rest/api/3/issue/${issueIdOrKey}`, { params: { deleteSubtasks }, }); } /** * Gets available transitions for an issue. * * @param issueIdOrKey - The issue key or ID * @returns Available transitions */ export async function getTransitions( issueIdOrKey: string ): Promise<JiraTransition[]> { logger.debug('Getting transitions', { issueIdOrKey }); const response = await getClient().get<{ transitions: JiraTransition[] }>( `/rest/api/3/issue/${issueIdOrKey}/transitions` ); return response.transitions; } /** * Transitions an issue to a new status. * * @param issueIdOrKey - The issue key or ID * @param transitionId - The transition ID * @param comment - Optional comment to add * * @example * await transitionIssue('PROJ-123', '21', 'Moving to In Progress'); */ export async function transitionIssue( issueIdOrKey: string, transitionId: string, comment?: string ): Promise<void> { logger.debug('Transitioning issue', { issueIdOrKey, transitionId }); const body: Record<string, unknown> = { transition: { id: transitionId }, }; if (comment) { // Convert markdown to ADF for proper formatting body['update'] = { comment: [ { add: { body: markdownToAdf(comment), }, }, ], }; } await getClient().post(`/rest/api/3/issue/${issueIdOrKey}/transitions`, { body, }); } /** * Assigns an issue to a user. * * @param issueIdOrKey - The issue key or ID * @param userIdentifier - The user's account ID, email, or display name (null to unassign) */ export async function assignIssue( issueIdOrKey: string, userIdentifier: string | null ): Promise<void> { logger.debug('Assigning issue', { issueIdOrKey, userIdentifier }); let accountId: string | null = null; if (userIdentifier) { accountId = await resolveUserIdentifier(userIdentifier); } await getClient().put(`/rest/api/3/issue/${issueIdOrKey}/assignee`, { body: { accountId }, }); } /** * Links an issue to an epic (parent). * Note: Epic Link custom field was deprecated Sept 2025, use parent field instead. * * @param issueIdOrKey - The issue key or ID to link * @param epicKey - The epic's issue key (or null to unlink) */ export async function linkToEpic( issueIdOrKey: string, epicKey: string | null ): Promise<void> { logger.debug('Linking issue to epic', { issueIdOrKey, epicKey }); await getClient().put(`/rest/api/3/issue/${issueIdOrKey}`, { body: { fields: { parent: epicKey ? { key: epicKey } : null, }, }, }); } /** * Gets the changelog for an issue. * * @param issueIdOrKey - The issue key or ID * @param startAt - Starting index for pagination * @param maxResults - Maximum results to return * @returns Changelog entries */ export async function getChangelog( issueIdOrKey: string, startAt: number = 0, maxResults: number = 100 ): Promise<{ startAt: number; maxResults: number; total: number; values: Array<{ id: string; author: JiraUser; created: string; items: Array<{ field: string; fieldtype: string; from: string | null; fromString: string | null; to: string | null; toString: string | null; }>; }>; }> { logger.debug('Getting changelog', { issueIdOrKey }); return getClient().get(`/rest/api/3/issue/${issueIdOrKey}/changelog`, { params: { startAt, maxResults }, }); } /** * Default minimal fields for LLM efficiency. * These fields provide enough context without overwhelming token usage. */ export const MINIMAL_ISSUE_FIELDS = [ 'summary', 'status', 'assignee', 'priority', 'issuetype', 'updated', 'parent', ]; /** * Full fields for detailed issue retrieval. */ export const FULL_ISSUE_FIELDS = ['*all']; /** * Searches for issues using JQL. * Uses the new /rest/api/3/search/jql endpoint (May 2025 migration). * * @param params - Search parameters * @returns Search results with pagination token * * @example * const results = await searchIssues({ * jql: 'project = PROJ AND status = "In Progress"', * maxResults: 50 * }); */ export async function searchIssues( params: JqlSearchParams ): Promise<JqlSearchResponse> { logger.debug('Searching issues', { jql: params.jql }); const queryParams: Record<string, string | number | undefined> = { jql: params.jql, maxResults: params.maxResults ?? 50, fields: (params.fields ?? MINIMAL_ISSUE_FIELDS).join(','), }; if (params.expand?.length) { queryParams['expand'] = params.expand.join(','); } if (params.nextPageToken) { queryParams['nextPageToken'] = params.nextPageToken; } return getClient().get<JqlSearchResponse>('/rest/api/3/search/jql', { params: queryParams, }); } /** * Searches all issues matching JQL, handling pagination automatically. * Yields issues one by one for memory efficiency. * * @param jql - The JQL query * @param fields - Fields to retrieve (defaults to minimal) * @param maxTotal - Maximum total issues to retrieve (default: 1000) */ export async function* searchAllIssues( jql: string, fields?: string[], maxTotal: number = 1000 ): AsyncGenerator<JiraIssue> { let nextPageToken: string | undefined; let count = 0; do { const response = await searchIssues({ jql, fields, maxResults: Math.min(100, maxTotal - count), nextPageToken, }); for (const issue of response.issues) { yield issue; count++; if (count >= maxTotal) return; } nextPageToken = response.nextPageToken; } while (nextPageToken && count < maxTotal); } /** * Gets all issues for a specific project. * * @param projectKey - The project key (e.g., "PROJ") * @param _startAt - Starting index for pagination (reserved for future use) * @param maxResults - Maximum results to return * @param fields - Optional fields to return * @returns Search results with pagination */ export async function getProjectIssues( projectKey: string, _startAt: number = 0, maxResults: number = 50, fields?: string[] ): Promise<JqlSearchResponse> { logger.debug('Getting project issues', { projectKey }); const jql = `project = "${projectKey}" ORDER BY updated DESC`; return searchIssues({ jql, maxResults, fields: fields ?? MINIMAL_ISSUE_FIELDS, }); } /** * Input for batch issue creation. */ export interface BatchCreateIssueInput { projectKey: string; summary: string; issueType: string; description?: string; priority?: string; assignee?: string; labels?: string[]; components?: string[]; parentKey?: string; customFields?: Record<string, unknown>; } /** * Creates multiple issues in a batch. * * @param issues - Array of issue creation inputs * @param validateOnly - If true, only validates without creating * @returns Array of created issues (or empty if validateOnly) */ export async function batchCreateIssues( issues: BatchCreateIssueInput[], validateOnly: boolean = false ): Promise<JiraIssue[]> { logger.debug('Batch creating issues', { count: issues.length, validateOnly }); if (issues.length === 0) { return []; } // Build issue updates with resolved assignees const issueUpdates: Array<{ fields: Record<string, unknown> }> = []; for (const input of issues) { const fields: Record<string, unknown> = { project: { key: input.projectKey }, summary: input.summary, issuetype: { name: input.issueType }, }; if (input.description) { // Convert markdown to ADF for proper formatting fields['description'] = markdownToAdf(input.description); } if (input.priority) { fields['priority'] = { name: input.priority }; } if (input.assignee) { // Resolve assignee by email, name, or account ID const accountId = await resolveUserIdentifier(input.assignee); fields['assignee'] = { accountId }; } if (input.labels?.length) { fields['labels'] = input.labels; } if (input.components?.length) { fields['components'] = input.components.map((name) => ({ name })); } if (input.parentKey) { fields['parent'] = { key: input.parentKey }; } if (input.customFields) { Object.assign(fields, input.customFields); } issueUpdates.push({ fields }); } if (validateOnly) { logger.info('Validation only - not creating issues'); return []; } const response = await getClient().post<{ issues: Array<{ id: string; key: string; self: string }>; errors: Array<{ status: number; elementErrors: Record<string, unknown> }>; }>('/rest/api/3/issue/bulk', { body: { issueUpdates } }); // Log any errors if (response.errors?.length) { for (const err of response.errors) { logger.error('Batch creation error', err as unknown as Error); } } // Fetch full issue data for created issues const createdIssues: JiraIssue[] = []; for (const issueInfo of response.issues || []) { if (issueInfo.key) { try { const issue = await getIssue(issueInfo.key); createdIssues.push(issue); } catch (fetchErr) { logger.error( `Failed to fetch created issue ${issueInfo.key}`, fetchErr as Error ); } } } return createdIssues; } /** * Changelog item from bulk fetch. */ export interface ChangelogItem { field: string; fieldtype: string; from: string | null; fromString: string | null; to: string | null; toString: string | null; } /** * Changelog entry from bulk fetch. */ export interface ChangelogEntry { id: string; author: JiraUser; created: string; items: ChangelogItem[]; } /** * Issue with changelogs from bulk fetch. */ export interface IssueChangelog { issueId: string; changelogs: ChangelogEntry[]; } /** * Gets changelogs for multiple issues in a batch (Cloud only). * * @param issueIdsOrKeys - Array of issue IDs or keys * @param fieldIds - Optional array of field IDs to filter changelogs * @returns Array of issues with their changelogs */ export async function batchGetChangelogs( issueIdsOrKeys: string[], fieldIds?: string[] ): Promise<IssueChangelog[]> { logger.debug('Batch getting changelogs', { count: issueIdsOrKeys.length }); const results: IssueChangelog[] = []; let startAt = 0; const maxResults = 100; // Fetch all pages while (true) { const response = await getClient().post<{ startAt: number; maxResults: number; total: number; isLast: boolean; issueChangeLogs: Array<{ issueId: string; changeHistories: ChangelogEntry[]; }>; }>('/rest/api/3/changelog/bulkfetch', { body: { issueIdsOrKeys, fieldIds: fieldIds || null, }, params: { startAt, maxResults }, }); for (const item of response.issueChangeLogs || []) { // Find existing entry or create new one let existing = results.find((r) => r.issueId === item.issueId); if (!existing) { existing = { issueId: item.issueId, changelogs: [] }; results.push(existing); } existing.changelogs.push(...(item.changeHistories || [])); } if (response.isLast) { break; } startAt += maxResults; } return results; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/icy-r/jira-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server