Skip to main content
Glama

mcp-gitlab-jira

jira.service.ts67 kB
import { Version3Client } from 'jira.js'; import { levenshteinDistance } from './utils.js'; import { JiraConfig, JiraTicket, JiraComment, JiraTransition, JiraTicketUpdatePayload, JiraField, JiraCustomFieldUpdatePayload, JiraSprint, JiraBoard, } from './jira'; // Types for Atlassian Document Format (ADF) interface ADFNode { type: string; text?: string; content?: ADFNode[]; attrs?: Record<string, unknown>; } export class JiraService { private client: Version3Client; private config: JiraConfig; private storyPointsFieldId: string | undefined; private allFieldsCache: JiraField[] | undefined; constructor(config: JiraConfig) { this.config = config; this.client = new Version3Client({ host: config.apiBaseUrl, authentication: { basic: { email: config.userEmail, apiToken: config.apiToken, }, }, }); } /** * Transform Jira issue fields into a flattened, user-friendly format */ private async transformIssueFields( issue: any, allFields: JiraField[], ): Promise<JiraTicket> { const fields = issue.fields || {}; const fieldMap = new Map<string, string>(); // Create a mapping from field ID to display name allFields.forEach((field) => { fieldMap.set(field.id, field.name); }); // Start with core fields that are always present const result: JiraTicket = { id: issue.id ?? '', key: issue.key ?? '', summary: fields.summary ?? '', description: this.extractDescription(fields.description), status: fields.status?.name ?? '', assignee: this.extractUser(fields.assignee), priority: fields.priority?.name ?? '', labels: fields.labels || [], updated: fields.updated ?? '', created: fields.created ?? '', issueType: fields.issuetype?.name ?? '', reporter: this.extractUser(fields.reporter), }; // Add other system and custom fields Object.keys(fields).forEach((fieldId) => { const fieldValue = fields[fieldId]; const fieldName = fieldMap.get(fieldId) || fieldId; // Skip if already handled above or if value is empty/null if (this.shouldSkipField(fieldId, fieldValue)) { return; } // Transform the field name to be more user-friendly const friendlyFieldName = this.transformFieldName(fieldName); // Transform the field value to be more readable const transformedValue = this.transformFieldValue(fieldValue); if (transformedValue !== null && transformedValue !== undefined) { result[friendlyFieldName] = transformedValue; } }); return result; } /** * Extract description from various formats */ private extractDescription(description: any): string { if (!description) return ''; if (typeof description === 'string') { return description; } if (description.content) { // Handle Atlassian Document Format (ADF) return this.extractTextFromADF(description.content); } return 'Description available (complex format)'; } /** * Extract text content from Atlassian Document Format - Enhanced version */ private extractTextFromADF(content: ADFNode[]): string { if (!Array.isArray(content)) return ''; const extractTextFromNode = (node: ADFNode): string => { if (!node) return ''; // Handle text nodes if (node.type === 'text') { return node.text || ''; } // Handle paragraph blocks if (node.type === 'paragraph' && node.content) { return node.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); } // Handle heading blocks if (node.type === 'heading' && node.content) { const headingText = node.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); return headingText ? `${headingText}` : ''; } // Handle list items if (node.type === 'listItem' && node.content) { return node.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); } // Handle bullet/ordered lists if ( (node.type === 'bulletList' || node.type === 'orderedList') && node.content ) { return node.content .map((item: ADFNode) => '• ' + extractTextFromNode(item)) .join('\n'); } // Handle code blocks if (node.type === 'codeBlock' && node.content) { const code = node.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); return code ? `\`\`\`\n${code}\n\`\`\`` : ''; } // Handle inline code if (node.type === 'code' && node.content) { const code = node.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); return code ? `\`${code}\`` : ''; } // Handle blockquotes if (node.type === 'blockquote' && node.content) { const quote = node.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); return quote ? `> ${quote}` : ''; } // Handle tables if (node.type === 'table' && node.content) { return node.content .map((row: ADFNode) => { if (row.type === 'tableRow' && row.content) { return row.content .map((cell: ADFNode) => { if (cell.type === 'tableCell' && cell.content) { return cell.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); } return ''; }) .join(' | '); } return ''; }) .filter((text: string) => text.trim().length > 0) .join('\n'); } // Handle hard breaks if (node.type === 'hardBreak') { return '\n'; } // Handle mentions if (node.type === 'mention' && node.attrs) { return `@${(node.attrs.text as string) || (node.attrs.displayName as string) || 'user'}`; } // Handle media/images if (node.type === 'media' && node.attrs) { return `[Image: ${(node.attrs.alt as string) || 'attachment'}]`; } // Handle links if (node.type === 'link' && node.content) { const linkText = node.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); return linkText; } // Recursively handle any node with content if (node.content && Array.isArray(node.content)) { return node.content .map((item: ADFNode) => extractTextFromNode(item)) .join(''); } // For unknown node types, try to extract any text property if (node.text) { return node.text; } return ''; }; // Process all top-level content blocks const extractedBlocks = content .map((block) => extractTextFromNode(block)) .filter((text) => text.trim().length > 0); // Join blocks with double newlines to preserve paragraph structure return extractedBlocks.join('\n\n'); } /** * Extract user information from user objects */ private extractUser( user: any, ): { displayName: string; emailAddress: string; accountId: string } | null { if (!user) return null; return { displayName: user.displayName ?? '', emailAddress: user.emailAddress ?? '', accountId: user.accountId ?? '', }; } /** * Determine if a field should be skipped */ private shouldSkipField(fieldId: string, fieldValue: any): boolean { // Skip core fields already handled const coreFields = [ 'id', 'key', 'summary', 'description', 'status', 'assignee', 'priority', 'labels', 'updated', 'created', 'issuetype', 'reporter', ]; if (coreFields.includes(fieldId)) { return true; } // Skip fields that are likely not useful for AI const skipFields = [ 'attachment', 'attachments', 'thumbnail', 'avatarUrls', 'avatar', 'worklog', 'timetracking', 'aggregatetimetracking', 'timeestimate', 'aggregatetimeestimate', 'timeoriginalestimate', 'aggregatetimeoriginalestimate', 'timespent', 'aggregatetimespent', 'workratio', 'progress', 'aggregateprogress', 'lastViewed', 'issuelinks', 'subtasks', 'versions', // Service desk related fields that often contain errors 'timeToCloseAfterResolution', 'timeToReviewNormalChange', 'timeToFirstResponse', 'timeToResolution', 'timeToDone', 'timeToTriageNormalChange', 'restrictTo', ]; if (skipFields.includes(fieldId)) { return true; } // Skip empty values if (fieldValue === null || fieldValue === undefined || fieldValue === '') { return true; } // Skip empty arrays if (Array.isArray(fieldValue) && fieldValue.length === 0) { return true; } // Skip empty objects if ( typeof fieldValue === 'object' && !Array.isArray(fieldValue) && Object.keys(fieldValue).length === 0 ) { return true; } // Skip fields that contain error messages if (typeof fieldValue === 'string' && fieldValue.includes('errorMessage')) { return true; } // Skip objects that contain error messages if (typeof fieldValue === 'object' && fieldValue.errorMessage) { return true; } return false; } /** * Transform field names to be more user-friendly */ private transformFieldName(fieldName: string): string { // Remove common prefixes/suffixes that are not user-friendly let transformed = fieldName .replace(/^(customfield_\d+|cf_)/i, '') // Remove custom field prefixes .replace(/\s*\[.*\]$/, '') // Remove bracketed suffixes .trim(); // Convert to camelCase if it's not already if ( transformed.includes(' ') || transformed.includes('_') || transformed.includes('-') ) { transformed = transformed .toLowerCase() .replace(/[^a-z0-9]+(.)/g, (_, char) => char.toUpperCase()) .replace(/^(.)/, (_, char) => char.toLowerCase()); } // Handle some common field mappings const fieldMappings: { [key: string]: string } = { storypoints: 'storyPoints', fixversion: 'fixVersions', fixversions: 'fixVersions', component: 'components', duedate: 'dueDate', resolutiondate: 'resolutionDate', lastviewed: 'lastViewed', timespent: 'timeSpent', timeestimate: 'timeEstimate', timeoriginalestimate: 'originalEstimate', issuecolor: 'issueColor', issuetype: 'issueType', }; return fieldMappings[transformed.toLowerCase()] || transformed; } /** * Transform field values to be more readable */ private transformFieldValue(fieldValue: any): any { if (fieldValue === null || fieldValue === undefined) { return null; } // Handle arrays if (Array.isArray(fieldValue)) { return fieldValue .map((item) => this.transformSingleValue(item)) .filter((item) => item !== null && item !== undefined); } return this.transformSingleValue(fieldValue); } /** * Transform a single field value */ private transformSingleValue(value: any): any { if (value === null || value === undefined) { return null; } // Handle string values (including JSON strings that should be parsed) if (typeof value === 'string') { // Check if it's a JSON string that contains error messages if ( value.includes('errorMessage') || value.includes('service project you are trying to view does not exist') ) { return null; // Skip error messages } // Try to parse JSON strings for better representation if (value.startsWith('{') && value.endsWith('}')) { try { const parsed = JSON.parse(value); return this.transformSingleValue(parsed); } catch { // If parsing fails, return the original string return value; } } return value; } // Handle objects with name property (common in Jira) if (typeof value === 'object' && value.name) { return value.name; } // Handle objects with displayName property if (typeof value === 'object' && value.displayName) { return value.displayName; } // Handle objects with value property if (typeof value === 'object' && value.value) { return value.value; } // Handle user objects if (typeof value === 'object' && value.accountId) { return value.displayName || value.emailAddress || value.accountId; } // Handle version objects if ( typeof value === 'object' && value.name && value.released !== undefined ) { return value.name; } // Handle parent/epic link objects if (typeof value === 'object' && value.key && value.fields) { const summary = value.fields.summary || ''; const status = value.fields.status?.name || ''; return `${value.key}: ${summary}${status ? ` (${status})` : ''}`; } // Handle objects that contain error messages if (typeof value === 'object' && value.errorMessage) { return null; // Skip objects with error messages } // Handle complex objects if (typeof value === 'object') { // Skip objects that look like they contain system metadata const systemKeys = ['self', 'id', 'iconUrl', 'avatarUrls', 'projectId']; const hasOnlySystemKeys = Object.keys(value).every((key) => systemKeys.includes(key), ); if (hasOnlySystemKeys) { return null; } // Handle objects with key-value pairs that might be useful const keys = Object.keys(value); if ( keys.length === 1 && (keys[0] === 'key' || keys[0] === 'name' || keys[0] === 'value') ) { return value[keys[0]]; } // For complex objects with multiple properties, create a readable summary const importantKeys = [ 'key', 'name', 'summary', 'status', 'value', 'displayName', ]; const importantData: { [key: string]: any } = {}; for (const key of importantKeys) { if (value[key] !== undefined && value[key] !== null) { importantData[key] = value[key]; } } // If we found important data, return a clean object if (Object.keys(importantData).length > 0) { return importantData; } // Otherwise, skip this field return null; } return value; } async getTicketDetails(ticketId: string): Promise<JiraTicket> { try { // Fetch the issue with all fields const issue = await this.client.issues.getIssue({ issueIdOrKey: ticketId, expand: [ 'names', 'schema', 'operations', 'editmeta', 'changelog', 'renderedFields', ], }); // Get field metadata for custom field name mapping const allFields = await this.getAllFields(); // Transform the fields into a flattened, user-friendly format const transformedTicket = await this.transformIssueFields( issue, allFields, ); return transformedTicket; } catch (error) { console.error( `Error fetching Jira ticket details for ${ticketId}:`, error, ); throw error; } } async getTicketComments(ticketId: string): Promise<JiraComment[]> { try { const commentsResponse = await this.client.issueComments.getComments({ issueIdOrKey: ticketId, }); return ( commentsResponse.comments?.map((comment: any) => ({ id: comment.id, author: { displayName: comment.author.displayName, emailAddress: comment.author.emailAddress, }, body: comment.body.content ?.map((block: any) => block.content?.map((item: any) => item.text).join(''), ) .join('\n'), created: comment.created, updated: comment.updated, })) || [] ); } catch (error) { console.error( `Error fetching Jira ticket comments for ${ticketId}:`, error, ); throw error; } } private async getStoryPointsFieldId(): Promise<string> { if (this.storyPointsFieldId) { return this.storyPointsFieldId; } try { const fields = await this.getAllFields(); const storyPointsField = fields.find( (field) => field.name ?.toLowerCase() .trim() .replace(/ /g, '') .replace(/_/g, '') .replace(/-/g, '') === 'storypoints', ); if (!storyPointsField || !storyPointsField.id) { throw new Error( 'Could not find the Story Points field for this Jira instance.', ); } this.storyPointsFieldId = storyPointsField.id; return this.storyPointsFieldId; } catch (error) { console.error('Error fetching Jira fields:', error); throw new Error('Could not retrieve custom field ID for Story Points.'); } } private async getFieldId(fieldName: string): Promise<string> { const allFields = await this.getAllFields(); const normalizedFieldName = fieldName .toLowerCase() .trim() .replace(/\s+|_|-/g, ' '); const rankedFields = allFields .map((field) => { const normalizedCandidateName = field.name .toLowerCase() .trim() .replace(/\s+|_|-/g, ' '); const distance = levenshteinDistance( normalizedFieldName, normalizedCandidateName, ); return { field, distance }; }) .sort((a, b) => a.distance - b.distance); if (rankedFields.length === 0) { throw new Error('No fields found in Jira.'); } const bestMatch = rankedFields[0]; if (!bestMatch.field.id) { throw new Error(`Could not find a field named "${fieldName}" in Jira.`); } return bestMatch.field.id; } async updateTicket( ticketId: string, payload: JiraTicketUpdatePayload, ): Promise<void> { try { const fields: { [key: string]: any } = {}; if (payload.summary) { fields.summary = payload.summary; } if (payload.labels) { fields.labels = payload.labels; } if (payload.description) { fields.description = { type: 'doc', version: 1, content: [ { type: 'paragraph', content: [{ type: 'text', text: payload.description }], }, ], }; } if (payload.assigneeAccountId) { fields.assignee = { accountId: payload.assigneeAccountId }; } if (payload.reporterAccountId) { fields.reporter = { accountId: payload.reporterAccountId }; } if (payload.priorityId) { fields.priority = { id: payload.priorityId }; } if (payload.fixVersions) { fields.fixVersions = payload.fixVersions.map((v) => ({ name: v })); } if (payload.components) { fields.components = payload.components.map((c) => ({ name: c })); } if (payload.duedate) { fields.duedate = payload.duedate; } await this.client.issues.editIssue({ issueIdOrKey: ticketId, fields, }); console.error(`Jira ticket ${ticketId} updated successfully.`); } catch (error) { console.error(`Error updating Jira ticket ${ticketId}:`, error); throw error; } } async updateCustomFields( ticketId: string, payload: JiraCustomFieldUpdatePayload, ): Promise<void> { try { const fields: { [key: string]: any } = {}; const allFields = await this.getAllFields(); const fieldMap = new Map<string, JiraField>(); // Create a mapping from field name/ID to field metadata allFields.forEach((field) => { fieldMap.set(field.id, field); fieldMap.set(field.name, field); }); for (const key in payload) { if (Object.prototype.hasOwnProperty.call(payload, key)) { const fieldId = await this.getFieldId(key); const fieldMetadata = fieldMap.get(fieldId) || fieldMap.get(key); const value = payload[key]; // Transform the value based on field type fields[fieldId] = this.transformValueForField(value, fieldMetadata); } } await this.client.issues.editIssue({ issueIdOrKey: ticketId, fields, }); console.error( `Jira ticket ${ticketId} custom fields updated successfully.`, ); } catch (error) { console.error( `Error updating Jira ticket custom fields ${ticketId}:`, error, ); throw error; } } /** * Transform values based on field type for proper JIRA API format */ private transformValueForField(value: any, fieldMetadata?: JiraField): any { if (!fieldMetadata || !fieldMetadata.schema) { return value; } const schema = fieldMetadata.schema; const fieldType = schema.type; const customType = schema.custom; // Handle Sprint fields (Greenhopper/Jira Software) if (customType === 'com.pyxis.greenhopper.jira:gh-sprint') { if (typeof value === 'number') { // Sprint ID - return as array of numbers return [value]; } else if (typeof value === 'string') { // Sprint name or ID string - try to parse as number first const numValue = parseInt(value, 10); if (!isNaN(numValue)) { return [numValue]; } // If not a number, it might be a sprint name - JIRA API typically expects IDs // For now, return as is and let JIRA handle the validation return [value]; } else if (Array.isArray(value)) { // Already an array, return as is return value; } return [value]; } // Handle Epic Link fields if (customType === 'com.pyxis.greenhopper.jira:gh-epic-link') { // Epic links expect the epic key as a string return String(value); } // Handle array fields if (fieldType === 'array') { if (!Array.isArray(value)) { return [value]; } return value; } // Handle option fields (dropdowns, selects) if (fieldType === 'option') { if (typeof value === 'string') { return { value: value }; } else if (typeof value === 'object' && value.value) { return value; } return { value: String(value) }; } // Handle user fields if (fieldType === 'user') { if (typeof value === 'string') { // Assume it's an accountId, email, or username return { accountId: value }; } return value; } // Handle number fields if (fieldType === 'number') { return Number(value); } // Handle date fields if (fieldType === 'date') { if (typeof value === 'string') { return value; // Assume it's already in YYYY-MM-DD format } return value; } // Handle datetime fields if (fieldType === 'datetime') { if (typeof value === 'string') { return value; // Assume it's already in ISO format } return value; } // For all other fields, return as is return value; } /** * Adds a comment to a Jira ticket. */ async addCommentToTicket(ticketId: string, comment: string): Promise<void> { try { await this.client.issueComments.addComment({ issueIdOrKey: ticketId, comment: { type: 'doc', version: 1, content: [ { type: 'paragraph', content: [{ type: 'text', text: comment }], }, ], }, }); console.error(`Comment added to Jira ticket ${ticketId}.`); } catch (error) { console.error(`Error adding comment to Jira ticket ${ticketId}:`, error); throw error; } } async searchTicketsByJQL(jql: string): Promise<JiraTicket[]> { try { // Debug logging for development if (process.env.NODE_ENV === 'development') { console.error('🔍 JQL Debug - Input:', jql); console.error('🔍 JQL Debug - API Endpoint:', this.config.apiBaseUrl); console.error('🔍 JQL Debug - Timestamp:', new Date().toISOString()); } const searchResults = await this.client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({ jql, fields: ['id', 'key', 'summary', 'description', 'status'], }); // Debug logging for results if (process.env.NODE_ENV === 'development') { console.error( '✅ JQL Debug - Result count:', searchResults.issues?.length || 0, ); if (searchResults.issues && searchResults.issues.length > 0) { console.error( '✅ JQL Debug - First result key:', searchResults.issues[0].key, ); } } return ( searchResults.issues?.map((issue: any) => ({ id: issue.id, key: issue.key, summary: issue.fields.summary ?? '', description: issue.fields.description?.content ?.map((block: any) => block.content?.map((item: any) => item.text).join(''), ) .join('\n'), status: issue.fields.status.name ?? '', })) || [] ); } catch (error) { console.error(`❌ JQL Debug - Error searching with JQL: ${jql}:`, error); // Enhanced error logging for debugging if (process.env.NODE_ENV === 'development') { console.error('❌ JQL Debug - Error details:', { message: error instanceof Error ? error.message : 'Unknown error', stack: error instanceof Error ? error.stack : undefined, jql: jql, timestamp: new Date().toISOString(), }); } throw error; } } /** * Creates a new Jira ticket using the provided parameters. * @param params The createIssue parameters for Jira API. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async createTicket(params: any): Promise<void> { try { if (params.fields) { // Jira's description field expects Atlassian Document Format (ADF). // If a plain string is provided, or if it's missing, convert it to the ADF structure. if ( typeof params.fields.description === 'string' || !params.fields.description ) { const descriptionText = params.fields.description || params.fields.summary || 'not provided by agent'; params.fields.description = { type: 'doc', version: 1, content: [ { type: 'paragraph', content: [{ type: 'text', text: descriptionText }], }, ], }; } } await this.client.issues.createIssue(params); console.error('Jira ticket created successfully.'); } catch (error) { console.error('Error creating Jira ticket:', error); throw error; } } async getAvailableTransitions(ticketId: string): Promise<JiraTransition[]> { try { const transitions = await this.client.issues.getTransitions({ issueIdOrKey: ticketId, }); return ( transitions.transitions ?.filter((transition) => transition.id && transition.name) .map((transition: any) => ({ id: transition.id!, name: transition.name!, to: { name: transition.to?.name || '', }, })) || [] ); } catch (error) { console.error( `Error fetching available transitions for Jira ticket ${ticketId}:`, error, ); throw error; } } async transitionTicket( ticketId: string, transitionId: string, ): Promise<void> { try { await this.client.issues.doTransition({ issueIdOrKey: ticketId, transition: { id: transitionId, }, }); console.error( `Jira ticket ${ticketId} transitioned successfully with transition ID ${transitionId}.`, ); } catch (error) { console.error(`Error transitioning Jira ticket ${ticketId}:`, error); throw error; } } async getAllFields(): Promise<JiraField[]> { if (this.allFieldsCache) { return this.allFieldsCache; } try { const fields = await this.client.issueFields.getFields(); this.allFieldsCache = fields as JiraField[]; return this.allFieldsCache; } catch (error) { console.error('Error fetching Jira fields:', error); throw error; } } // New tool: Get All Projects async getAllProjects(): Promise<any[]> { try { // Use searchProjects instead of getAllProjects const projects = await this.client.projects.searchProjects(); return projects.values || []; } catch (error) { console.error('Error fetching Jira projects:', error); throw error; } } // New tool: Get Project Details async getProjectDetails(projectKey: string): Promise<any> { try { const project = await this.client.projects.getProject({ projectIdOrKey: projectKey, }); return project; } catch (error) { console.error( `Error fetching Jira project details for ${projectKey}:`, error, ); throw error; } } // New tool: Get Project Components async getProjectComponents(projectKey: string): Promise<any[]> { try { const components = await this.client.projectComponents.getProjectComponents({ projectIdOrKey: projectKey, }); return components; } catch (error) { console.error( `Error fetching components for project ${projectKey}:`, error, ); throw error; } } // New tool: Get Project Versions async getProjectVersions(projectKey: string): Promise<any[]> { try { const versions = await this.client.projectVersions.getProjectVersions({ projectIdOrKey: projectKey, }); return versions; } catch (error) { console.error( `Error fetching versions for project ${projectKey}:`, error, ); throw error; } } /** * Unified search method that accepts multiple criteria and builds JQL dynamically * This replaces all the individual search methods with a single flexible interface */ async searchTickets( criteria: { projectKey?: string; assigneeEmail?: string; assignedToMe?: boolean; statusCategory?: 'To Do' | 'In Progress' | 'Done'; status?: string; priority?: string; labels?: string[]; labelsMatchAll?: boolean; updatedSince?: string; updatedBefore?: string; createdSince?: string; createdBefore?: string; recentDays?: number; issueType?: string; reporter?: string; text?: string; maxResults?: number; orderBy?: string; } = {}, ): Promise<JiraTicket[]> { const maxResults = criteria.maxResults || 50; const orderBy = criteria.orderBy || 'updated DESC'; // Build JQL conditions array const conditions: string[] = []; // Project filter - use fuzzy matching if not exact match if (criteria.projectKey) { // Try exact match first, then fuzzy if it contains spaces or looks partial if (criteria.projectKey.includes(' ') || criteria.projectKey.length < 3) { conditions.push( `project in projectsWhere("name ~ '${criteria.projectKey}' OR key ~ '${criteria.projectKey}'")`, ); } else { conditions.push(`project = "${criteria.projectKey}"`); } } // Assignee filters if (criteria.assignedToMe) { conditions.push('assignee = currentUser()'); } else if (criteria.assigneeEmail) { // Support fuzzy matching for assignee - try email, display name, or username if (criteria.assigneeEmail.includes('@')) { conditions.push(`assignee = "${criteria.assigneeEmail}"`); } else { // Fuzzy match by display name or username conditions.push( `assignee in membersOf("jira-users") AND assignee ~ "${criteria.assigneeEmail}"`, ); } } // Status filters if (criteria.statusCategory) { conditions.push(`statusCategory = "${criteria.statusCategory}"`); } if (criteria.status) { // Status fuzzy matching for common variations const statusMap: { [key: string]: string[] } = { open: ['Open', 'To Do', 'New'], progress: ['In Progress', 'In Development', 'In Review'], review: ['In Review', 'Code Review', 'Peer Review'], testing: ['Testing', 'QA', 'In Testing'], done: ['Done', 'Closed', 'Resolved', 'Complete'], closed: ['Closed', 'Done', 'Resolved'], }; const lowerStatus = criteria.status.toLowerCase(); const matches = statusMap[lowerStatus]; if (matches) { const statusConditions = matches.map((s) => `status = "${s}"`); conditions.push(`(${statusConditions.join(' OR ')})`); } else { conditions.push(`status = "${criteria.status}"`); } } // Priority filter - with fuzzy matching if (criteria.priority) { // Common priority fuzzy matching const priorityMap: { [key: string]: string[] } = { high: ['High', 'Highest', 'Critical', 'Urgent'], medium: ['Medium', 'Normal'], low: ['Low', 'Lowest', 'Minor', 'Trivial'], critical: ['Critical', 'Highest'], urgent: ['Urgent', 'High', 'Highest'], }; const lowerPriority = criteria.priority.toLowerCase(); const matches = priorityMap[lowerPriority]; if (matches) { const priorityConditions = matches.map((p) => `priority = "${p}"`); conditions.push(`(${priorityConditions.join(' OR ')})`); } else { conditions.push(`priority = "${criteria.priority}"`); } } // Issue type filter - with fuzzy matching if (criteria.issueType) { const typeMap: { [key: string]: string[] } = { bug: ['Bug', 'Defect', 'Issue'], story: ['Story', 'User Story'], task: ['Task', 'To Do'], epic: ['Epic'], feature: ['Feature', 'New Feature', 'Story'], }; const lowerType = criteria.issueType.toLowerCase(); const matches = typeMap[lowerType]; if (matches) { const typeConditions = matches.map((t) => `issueType = "${t}"`); conditions.push(`(${typeConditions.join(' OR ')})`); } else { conditions.push(`issueType = "${criteria.issueType}"`); } } // Reporter filter - with fuzzy matching if (criteria.reporter) { if (criteria.reporter.includes('@')) { conditions.push(`reporter = "${criteria.reporter}"`); } else { // Fuzzy match by display name or username conditions.push( `reporter in membersOf("jira-users") AND reporter ~ "${criteria.reporter}"`, ); } } // Labels filter if (criteria.labels && criteria.labels.length > 0) { const labelConditions = criteria.labels.map( (label) => `labels = "${label}"`, ); const labelQuery = criteria.labelsMatchAll ? labelConditions.join(' AND ') : labelConditions.join(' OR '); conditions.push(`(${labelQuery})`); } // Date filters if (criteria.recentDays) { conditions.push(`updated >= -${criteria.recentDays}d`); } else { if (criteria.updatedSince) { conditions.push(`updated >= "${criteria.updatedSince}"`); } if (criteria.updatedBefore) { conditions.push(`updated <= "${criteria.updatedBefore}"`); } } if (criteria.createdSince) { conditions.push(`created >= "${criteria.createdSince}"`); } if (criteria.createdBefore) { conditions.push(`created <= "${criteria.createdBefore}"`); } // Text search - enhanced fuzzy matching across multiple fields if (criteria.text) { const searchTerms = criteria.text.trim().split(/\s+/); if (searchTerms.length === 1) { // Single term - search across summary, description, and comments conditions.push( `(summary ~ "${criteria.text}" OR description ~ "${criteria.text}" OR comment ~ "${criteria.text}")`, ); } else { // Multiple terms - each term should appear somewhere const termConditions = searchTerms.map( (term) => `(summary ~ "${term}" OR description ~ "${term}" OR comment ~ "${term}")`, ); conditions.push(`(${termConditions.join(' AND ')})`); } } // If no conditions specified, search for recent tickets as default if (conditions.length === 0) { conditions.push('updated >= -7d'); } // Build final JQL const jql = conditions.join(' AND ') + ` ORDER BY ${orderBy}`; // Debug logging for development if (process.env.NODE_ENV === 'development') { console.error('🔍 Unified Search Debug - Generated JQL:', jql); console.error( '🔍 Unified Search Debug - Criteria:', JSON.stringify(criteria, null, 2), ); } try { const searchResults = await this.client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({ jql, maxResults, fields: [ 'id', 'key', 'summary', 'description', 'status', 'assignee', 'priority', 'labels', 'updated', 'created', 'issuetype', 'reporter', ], }); return this.mapSearchResultsToTickets(searchResults); } catch (error) { console.error(`Error in unified ticket search:`, error); console.error('Generated JQL:', jql); throw error; } } /** * Helper method to map search results to JiraTicket objects */ private mapSearchResultsToTickets(searchResults: { issues?: Array<{ id: string; key: string; fields: { summary?: string; description?: { content?: Array<{ content?: Array<{ text: string }>; }>; }; status?: { name?: string }; assignee?: { displayName?: string; emailAddress?: string; accountId?: string; }; priority?: { name?: string }; labels?: string[]; updated?: string; created?: string; issuetype?: { name?: string }; reporter?: { displayName?: string; emailAddress?: string; accountId?: string; }; }; }>; }): JiraTicket[] { return ( searchResults.issues?.map((issue) => ({ id: issue.id, key: issue.key, summary: issue.fields.summary ?? '', description: typeof issue.fields.description === 'string' ? issue.fields.description : issue.fields.description?.content ? 'Description available (complex format)' : '', status: issue.fields.status?.name ?? '', assignee: issue.fields.assignee ? { displayName: issue.fields.assignee.displayName ?? '', emailAddress: issue.fields.assignee.emailAddress ?? '', accountId: issue.fields.assignee.accountId ?? '', } : null, priority: issue.fields.priority?.name ?? '', labels: issue.fields.labels || [], updated: issue.fields.updated ?? '', created: issue.fields.created ?? '', issueType: issue.fields.issuetype?.name ?? '', reporter: issue.fields.reporter ? { displayName: issue.fields.reporter.displayName ?? '', emailAddress: issue.fields.reporter.emailAddress ?? '', accountId: issue.fields.reporter.accountId ?? '', } : null, })) || [] ); } /** * Helper method to make Agile API requests since jira.js doesn't include agile endpoints */ private async makeAgileRequest( endpoint: string, options: RequestInit = {}, ): Promise<any> { const url = `${this.config.apiBaseUrl}/rest/agile/1.0${endpoint}`; const auth = btoa(`${this.config.userEmail}:${this.config.apiToken}`); const response = await fetch(url, { ...options, headers: { Authorization: `Basic ${auth}`, Accept: 'application/json', 'Content-Type': 'application/json', ...options.headers, }, }); if (!response.ok) { throw new Error( `Agile API request failed: ${response.status} ${response.statusText}`, ); } return response.json(); } /** * Get all boards accessible to the user * Note: projectKeyOrId and maxResults parameters don't work reliably in Jira Agile API * This method handles pagination automatically to fetch all boards */ async getAllBoards( options: { type?: 'scrum' | 'kanban'; name?: string; } = {}, ): Promise<{ values: JiraBoard[]; total: number; isLast: boolean }> { try { const pageSize = 50; // Fixed page size since maxResults parameter doesn't work let startAt = 0; let allBoards: JiraBoard[] = []; let totalCount = 0; let isLastPage = false; do { const params = new URLSearchParams(); params.append('startAt', startAt.toString()); params.append('maxResults', pageSize.toString()); if (options.type) params.append('type', options.type); if (options.name) params.append('name', options.name); // Note: projectKeyOrId parameter removed as it doesn't work reliably // Callers should filter boards manually using board.location.projectKey const endpoint = `/board${params.toString() ? '?' + params.toString() : ''}`; const result = await this.makeAgileRequest(endpoint); const currentPageBoards = result.values || []; allBoards = allBoards.concat(currentPageBoards); totalCount = result.total || 0; isLastPage = result.isLast || false; // Prepare for next page startAt += pageSize; // Safety check to prevent infinite loops - only break if we already have all items if (!isLastPage && allBoards.length >= totalCount) { isLastPage = true; } } while (!isLastPage); return { values: allBoards, total: totalCount, isLast: isLastPage, }; } catch (error) { console.error('Error fetching boards:', error); throw error; } } /** * Get board details by ID */ async getBoardById(boardId: number): Promise<JiraBoard> { try { return await this.makeAgileRequest(`/board/${boardId}`); } catch (error) { console.error(`Error fetching board ${boardId}:`, error); throw error; } } /** * Get all sprints for a board * This method handles pagination automatically to fetch all sprints */ async getSprintsForBoard( boardId: number, options: { state?: 'future' | 'active' | 'closed'; } = {}, ): Promise<{ values: JiraSprint[]; total: number; isLast: boolean }> { try { const pageSize = 50; // Fixed page size since maxResults parameter doesn't work let startAt = 0; let allSprints: JiraSprint[] = []; let totalCount = 0; let isLastPage = false; do { const params = new URLSearchParams(); params.append('startAt', startAt.toString()); params.append('maxResults', pageSize.toString()); if (options.state) params.append('state', options.state); const endpoint = `/board/${boardId}/sprint${params.toString() ? '?' + params.toString() : ''}`; const result = await this.makeAgileRequest(endpoint); const currentPageSprints = result.values || []; allSprints = allSprints.concat(currentPageSprints); totalCount = result.total || 0; isLastPage = result.isLast || false; // Prepare for next page startAt += pageSize; // Safety check to prevent infinite loops - only break if we already have all items if (!isLastPage && allSprints.length >= totalCount) { isLastPage = true; } } while (!isLastPage); return { values: allSprints, total: totalCount, isLast: isLastPage, }; } catch (error) { console.error(`Error fetching sprints for board ${boardId}:`, error); throw error; } } /** * Get sprint details by ID */ async getSprintById(sprintId: number): Promise<JiraSprint> { try { return await this.makeAgileRequest(`/sprint/${sprintId}`); } catch (error) { console.error(`Error fetching sprint ${sprintId}:`, error); throw error; } } /** * Search for sprints in a specific board by name or ID */ async searchSprints( criteria: { boardName?: string; boardId?: number; name?: string; state?: 'future' | 'active' | 'closed'; maxResults?: number; } = {}, ): Promise<JiraSprint[]> { try { const maxResults = criteria.maxResults || 50; let targetBoardId: number; // Determine the target board ID if (criteria.boardId) { targetBoardId = criteria.boardId; } else if (criteria.boardName) { // Search for board by name const boardsResult = await this.getAllBoards({ name: criteria.boardName, }); if (boardsResult.values.length === 0) { throw new Error( `No board found with name containing: ${criteria.boardName}`, ); } // Use the first matching board targetBoardId = boardsResult.values[0].id; } else { throw new Error('Either boardName or boardId must be provided'); } // Get sprints from the target board const sprintResult = await this.getSprintsForBoard(targetBoardId, { state: criteria.state, }); let allSprints = sprintResult.values; // Filter by name if provided if (criteria.name) { const nameLower = criteria.name.toLowerCase(); allSprints = allSprints.filter((sprint) => sprint.name.toLowerCase().includes(nameLower), ); } // Sort by state priority (active > future > closed) and then by name allSprints.sort((a, b) => { const stateOrder = { active: 1, future: 2, closed: 3 }; const stateComparison = stateOrder[a.state] - stateOrder[b.state]; if (stateComparison !== 0) return stateComparison; return a.name.localeCompare(b.name); }); // Apply maxResults limit if (allSprints.length > maxResults) { allSprints = allSprints.slice(0, maxResults); } return allSprints; } catch (error) { console.error('Error searching sprints:', error); throw error; } } /** * Get issues in a specific sprint */ async getIssuesForSprint( sprintId: number, options: { startAt?: number; maxResults?: number; jql?: string; fields?: string[]; } = {}, ): Promise<JiraTicket[]> { try { const params = new URLSearchParams(); if (options.startAt !== undefined) params.append('startAt', options.startAt.toString()); if (options.maxResults !== undefined) params.append('maxResults', options.maxResults.toString()); if (options.jql) params.append('jql', options.jql); if (options.fields) params.append('fields', options.fields.join(',')); const endpoint = `/sprint/${sprintId}/issue${params.toString() ? '?' + params.toString() : ''}`; const result = await this.makeAgileRequest(endpoint); // Transform the issues using existing transformation logic const allFields = await this.getAllFields(); return await Promise.all( (result.issues || []).map((issue: any) => this.transformIssueFields(issue, allFields), ), ); } catch (error) { console.error(`Error fetching issues for sprint ${sprintId}:`, error); throw error; } } /** * Update story points for a ticket */ async updateTicketStoryPoints( ticketId: string, storyPoints: number, ): Promise<void> { try { const storyPointsFieldId = await this.getStoryPointsFieldId(); const updatePayload = { fields: { [storyPointsFieldId]: storyPoints, }, }; await this.client.issues.editIssue({ issueIdOrKey: ticketId, ...updatePayload, }); } catch (error) { console.error( `Error updating story points for ticket ${ticketId}:`, error, ); throw error; } } /** * Get allowed values for a specific field from a ticket's edit metadata */ private async getFieldAllowedValues( ticketId: string, fieldId: string, ): Promise<unknown[]> { try { const issue = await this.client.issues.getIssue({ issueIdOrKey: ticketId, expand: ['editmeta'], }); const editmeta = issue.editmeta as any; if (!editmeta || !editmeta.fields || !editmeta.fields[fieldId]) { throw new Error( `Field ${fieldId} not found in edit metadata or not editable.`, ); } const fieldMeta = editmeta.fields[fieldId] as any; if (!fieldMeta.allowedValues) { throw new Error( `Field ${fieldId} does not have allowed values (may not be an option field).`, ); } return fieldMeta.allowedValues; } catch (error) { // console.error(`Error fetching allowed values for field ${fieldId}:`, error); throw error; } } /** * Find the best matching option from allowed values based on display name */ private findBestMatchingOption( inputValue: string, allowedValues: unknown[], ): unknown { if (!allowedValues || allowedValues.length === 0) { throw new Error('No allowed values available for this field.'); } const normalizedInput = inputValue.toLowerCase().trim(); // First try exact match on value or name const exactMatch = allowedValues.find((option) => { const opt = option as any; const value = opt.value?.toLowerCase().trim(); const name = opt.name?.toLowerCase().trim(); return value === normalizedInput || name === normalizedInput; }); if (exactMatch) { return exactMatch; } // Try fuzzy matching using levenshtein distance let bestMatch = allowedValues[0]; let bestDistance = Infinity; for (const option of allowedValues) { const opt = option as any; const valueName = opt.value || opt.name || ''; const distance = levenshteinDistance( normalizedInput, valueName.toLowerCase(), ); if (distance < bestDistance) { bestDistance = distance; bestMatch = option; } } // If the best match distance is too high, suggest available options if (bestDistance > Math.max(Math.min(normalizedInput.length, 4), 2)) { const availableOptions = allowedValues .map((opt) => (opt as any).value || (opt as any).name) .join(', '); throw new Error( `No close match found for "${inputValue}". Available options: ${availableOptions}`, ); } // console.error(`Using fuzzy match: "${inputValue}" -> "${(bestMatch as any).value || (bestMatch as any).name}" (distance: ${bestDistance})`); return bestMatch; } /** * Get the Priority field ID by searching for "Priority" or "priority" in field names */ private async getPriorityFieldId(): Promise<string> { try { const fields = await this.getAllFields(); const priorityField = fields.find((field) => { const fieldNameNormalized = field.name ?.toLowerCase() .trim() .replace(/ /g, '') .replace(/_/g, '') .replace(/-/g, ''); return fieldNameNormalized === 'priority'; }); if (!priorityField || !priorityField.id) { throw new Error( 'Could not find the Priority custom field for this Jira instance.', ); } return priorityField.id; } catch { // console.error('Error fetching Jira fields for priority:', error); throw new Error('Could not retrieve custom field ID for Priority.'); } } /** * Get the Sprint field ID by searching for Sprint fields with specific custom types */ private async getSprintFieldId(): Promise<string> { try { const fields = await this.getAllFields(); const sprintField = fields.find((field) => { // Look for Greenhopper Sprint fields return ( field.schema?.custom === 'com.pyxis.greenhopper.jira:gh-sprint' || field.name?.toLowerCase().includes('sprint') ); }); if (!sprintField || !sprintField.id) { throw new Error( 'Could not find the Sprint custom field for this Jira instance.', ); } return sprintField.id; } catch { // console.error('Error fetching Jira fields for sprint:', error); throw new Error('Could not retrieve custom field ID for Sprint.'); } } /** * Get the project key from a ticket */ private async getTicketProjectKey(ticketId: string): Promise<string> { try { const issue = await this.client.issues.getIssue({ issueIdOrKey: ticketId, fields: ['project'], }); const projectKey = issue.fields.project?.key; if (!projectKey) { throw new Error( `Could not determine project key for ticket ${ticketId}.`, ); } return projectKey; } catch (error) { console.error( `Error fetching project key for ticket ${ticketId}:`, error, ); throw error; } } /** * Get available sprints for a ticket's project */ private async getAvailableSprintsForTicket( ticketId: string, ): Promise<JiraSprint[]> { const projectKey = await this.getTicketProjectKey(ticketId); // Get all boards and filter by project manually since projectKeyOrId param doesn't work reliably const allBoardsResult = await this.getAllBoards(); // Filter boards that are associated with the project const projectBoards = allBoardsResult.values.filter(board => { // Primary filter: check location.projectKey for exact match if (board.location?.projectKey === projectKey) { return true; } // Secondary filter: check if board name contains project key if (board.name?.toLowerCase().includes(projectKey.toLowerCase())) { return true; } return false; }); const boardsResult = { values: projectBoards, total: projectBoards.length, isLast: true }; if (boardsResult.values.length === 0) { // Show all available boards to help user identify the right one const helpBoardsResult = await this.getAllBoards(); const boardList = helpBoardsResult.values.slice(0, 50).map(b => `"${b.name}" (ID: ${b.id}, Type: ${b.type})`).join(', '); throw new Error( `No boards found for project ${projectKey}. Available boards: ${boardList}. Consider using boardId parameter to specify the exact board.`, ); } // Debug logging to help identify boards // Prioritize boards by type and activity const prioritizedBoards = boardsResult.values.sort((a, b) => { // Prefer Scrum boards over Kanban for sprint management if (a.type === 'scrum' && b.type !== 'scrum') return -1; if (b.type === 'scrum' && a.type !== 'scrum') return 1; // Sort by name as secondary criteria return a.name.localeCompare(b.name); }); // Get sprints from boards (prioritizing the first/most relevant board) const allSprints: JiraSprint[] = []; const boardErrors: string[] = []; for (const board of prioritizedBoards) { try { const sprintsResult = await this.getSprintsForBoard(board.id); allSprints.push(...sprintsResult.values); } catch (error) { const errorMsg = `Error fetching sprints for board "${board.name}" (ID: ${board.id}): ${error}`; console.error(errorMsg); boardErrors.push(errorMsg); // Continue with other boards } } if (allSprints.length === 0) { const boardList = prioritizedBoards.map(b => `"${b.name}" (ID: ${b.id}, Type: ${b.type})`).join(', '); const errorDetails = boardErrors.length > 0 ? `\nErrors encountered: ${boardErrors.join('; ')}` : ''; throw new Error( `No sprints found for project ${projectKey}. Checked boards: ${boardList}.${errorDetails}` ); } // Remove duplicates based on sprint ID and filter out sprints without names const uniqueSprints = allSprints.filter( (sprint, index, self) => sprint.name && // Only include sprints with names index === self.findIndex((s) => s.id === sprint.id), ); // Sort by state (active first, then future, then closed) and name uniqueSprints.sort((a, b) => { const stateOrder = { active: 1, future: 2, closed: 3 }; const stateComparison = stateOrder[a.state] - stateOrder[b.state]; if (stateComparison !== 0) return stateComparison; return (a.name || '').localeCompare(b.name || ''); }); return uniqueSprints; } /** * Find the best matching sprint from available sprints based on display name */ private findBestMatchingSprint( inputValue: string, availableSprints: JiraSprint[], ): JiraSprint { if (!availableSprints || availableSprints.length === 0) { throw new Error('No available sprints for this project.'); } const normalizedInput = inputValue?.toLowerCase().trim() || ''; // First try exact match on name const exactMatch = availableSprints.find((sprint) => { const name = sprint.name?.toLowerCase().trim(); return name === normalizedInput; }); if (exactMatch) { return exactMatch; } // Try fuzzy matching using levenshtein distance on sprint names let bestMatch = availableSprints[0]; let bestDistance = Infinity; for (const sprint of availableSprints) { const sprintName = sprint.name || ''; const distance = levenshteinDistance( normalizedInput, sprintName.toLowerCase(), ); if (distance < bestDistance) { bestDistance = distance; bestMatch = sprint; } } // If the best match distance is too high, suggest available options // Allow more lenient matching for sprints since they often have descriptive names const threshold = Math.max(Math.min(normalizedInput.length * 2, 10), 4); if (bestDistance > threshold) { const availableOptions = availableSprints .slice(0, 10) // Limit to first 10 for readability .map((sprint) => `${sprint.name || 'Unnamed Sprint'} (${sprint.state})`) .join(', '); const moreText = availableSprints.length > 10 ? ` and ${availableSprints.length - 10} more` : ''; throw new Error( `No close match found for "${inputValue}". Available sprints: ${availableOptions}${moreText}`, ); } // console.error(`Using fuzzy match: "${inputValue}" -> "${bestMatch.name}" (${bestMatch.state}, distance: ${bestDistance})`); return bestMatch; } /** * Update priority for a ticket */ async updateTicketPriority( ticketId: string, priority: string, ): Promise<void> { const priorityFieldId = await this.getPriorityFieldId(); // Get allowed values for the priority field const allowedValues = await this.getFieldAllowedValues( ticketId, priorityFieldId, ); // Find the best matching option const matchedOption = this.findBestMatchingOption(priority, allowedValues); // Use the matched option (typically need the ID or value) const updatePayload = { fields: { [priorityFieldId]: matchedOption, }, }; await this.client.issues.editIssue({ issueIdOrKey: ticketId, ...updatePayload, }); // console.error(`Priority for ticket ${ticketId} updated to ${optionName} successfully.`); } /** * Update sprint for a ticket */ async updateTicketSprint( ticketId: string, sprintName: string, boardId?: number, ): Promise<void> { const sprintFieldId = await this.getSprintFieldId(); // Get available sprints for the ticket's project const availableSprints = boardId ? await this.getAvailableSprintsForBoard(boardId) : await this.getAvailableSprintsForTicket(ticketId); // Find the best matching sprint const matchedSprint = this.findBestMatchingSprint( sprintName, availableSprints, ); // Ensure sprint ID is a number const sprintId = typeof matchedSprint.id === 'string' ? parseInt(matchedSprint.id, 10) : matchedSprint.id; if (isNaN(sprintId)) { throw new Error(`Invalid sprint ID: ${matchedSprint.id}. Expected a number.`); } // Sprint fields expect the sprint ID as a number const updatePayload = { fields: { [sprintFieldId]: sprintId, }, }; // Debug logging for development if (process.env.NODE_ENV === 'development') { console.error('🔍 Sprint Update Debug - Sprint Field ID:', sprintFieldId); console.error('🔍 Sprint Update Debug - Matched Sprint:', { id: matchedSprint.id, name: matchedSprint.name, state: matchedSprint.state }); console.error('🔍 Sprint Update Debug - Final Sprint ID:', sprintId, 'Type:', typeof sprintId); console.error('🔍 Sprint Update Debug - Update Payload:', JSON.stringify(updatePayload, null, 2)); } await this.client.issues.editIssue({ issueIdOrKey: ticketId, ...updatePayload, }); // console.error(`Sprint for ticket ${ticketId} updated to "${matchedSprint.name}" (${matchedSprint.state}) successfully.`); } /** * Get available sprints for a specific board (alternative to project-based lookup) */ private async getAvailableSprintsForBoard(boardId: number): Promise<JiraSprint[]> { try { // First verify the board exists try { await this.getBoardById(boardId); } catch (boardError) { console.error(`Board ${boardId} not found or not accessible:`, boardError); // Show available boards to help user const allBoardsResult = await this.getAllBoards(); const boardList = allBoardsResult.values.slice(0, 50).map(b => `"${b.name}" (ID: ${b.id}, Type: ${b.type})`).join(', '); throw new Error(`Board ${boardId} not found. Available boards: ${boardList}`); } const sprintsResult = await this.getSprintsForBoard(boardId); // Filter out sprints without names const validSprints = sprintsResult.values.filter(sprint => sprint.name); // Sort by state (active first, then future, then closed) and name validSprints.sort((a, b) => { const stateOrder = { active: 1, future: 2, closed: 3 }; const stateComparison = stateOrder[a.state] - stateOrder[b.state]; if (stateComparison !== 0) return stateComparison; return (a.name || '').localeCompare(b.name || ''); }); return validSprints; } catch (error) { console.error(`Error fetching sprints for board ${boardId}:`, error); throw error; } } /** * Search for boards by various criteria to help identify the correct board */ async findBoards(criteria: { projectKey?: string; boardName?: string; boardId?: number; type?: 'scrum' | 'kanban'; maxResults?: number; } = {}): Promise<JiraBoard[]> { try { const maxResults = criteria.maxResults || 50; if (criteria.boardId) { // If specific board ID provided, get that board directly try { const board = await this.getBoardById(criteria.boardId); return [board]; } catch (error) { console.error(`Board ${criteria.boardId} not found:`, error); return []; } } // Get all boards and filter manually since API query parameters don't work reliably const allBoardsResult = await this.getAllBoards({ type: criteria.type, name: criteria.boardName, // This parameter might work for name filtering }); let boards = allBoardsResult.values; // Apply manual filters if (criteria.projectKey) { boards = boards.filter(board => { // Primary filter: check location.projectKey for exact match if (board.location?.projectKey === criteria.projectKey) { return true; } // Secondary filter: check if board name contains project key if (board.name?.toLowerCase().includes(criteria.projectKey!.toLowerCase())) { return true; } return false; }); } if (criteria.boardName) { // Apply name filtering manually for better control const nameLower = criteria.boardName.toLowerCase(); boards = boards.filter(board => board.name?.toLowerCase().includes(nameLower) ); } // Limit results to requested maxResults if (boards.length > maxResults) { boards = boards.slice(0, maxResults); } return boards; } catch (error) { console.error('Error searching for boards:', error); throw error; } } // Note: Sprint and board functionality now implemented using JIRA Agile API // The agile endpoints are available at /rest/agile/1.0/ and require proper permissions // Sprint/board features can now be accessed through the methods above. }

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/HainanZhao/mcp-gitlab-jira'

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