UNPKG

ticket-hero-sdk

Version:
436 lines (369 loc) 14 kB
// jira-helper.js const JiraApi = require('jira-client'); const keytar = require('keytar'); const chalk = require('chalk'); const SERVICE_NAME = 'ticket-hero-jira'; /** * Manages Jira integration and authentication */ class JiraHelper { constructor() { this.jira = null; this.isAuthenticated = false; } /** * Get stored credentials */ async getStoredCredentials() { try { const credentials = await keytar.findCredentials(SERVICE_NAME); if (credentials && credentials.length > 0) { return { host: await keytar.getPassword(SERVICE_NAME, 'host'), username: credentials[0].account, password: credentials[0].password }; } } catch (error) { console.error('Error retrieving stored credentials:', error); } return null; } /** * Save credentials securely */ async saveCredentials(host, username, password) { try { await keytar.setPassword(SERVICE_NAME, username, password); await keytar.setPassword(SERVICE_NAME, 'host', host); return true; } catch (error) { console.error('Error saving credentials:', error); return false; } } /** * Initialize Jira client */ async initialize() { const credentials = await this.getStoredCredentials(); if (credentials) { return this.connect( credentials.host, credentials.username, credentials.password ); } return false; } /** * Connect to Jira */ async connect(host, username, password) { try { this.jira = new JiraApi({ protocol: 'https', host, username, password, apiVersion: '2', strictSSL: true }); // Test the connection await this.jira.getCurrentUser(); this.isAuthenticated = true; return true; } catch (error) { console.error(chalk.red('Error connecting to Jira:'), error.message); this.isAuthenticated = false; return false; } } /** * Setup Jira connection with user input */ async setupJira(rl) { console.clear(); console.log(chalk.bold.blue('===== Jira Integration Setup =====\n')); console.log(chalk.yellow('NOTE: For Jira Cloud, you need to use an API token as your password.')); console.log(chalk.yellow('You can create an API token at: https://id.atlassian.com/manage-profile/security/api-tokens\n')); // Using readline interface for consistent UI const host = await new Promise(resolve => { rl.question(chalk.yellow('Jira Host (e.g., mycompany.atlassian.net): '), answer => { resolve(answer.trim()); }); }); const username = await new Promise(resolve => { rl.question(chalk.yellow('Jira Email/Username: '), answer => { resolve(answer.trim()); }); }); // For password input console.log(chalk.yellow('Jira API Token/Password: ')); let password = ''; // Set up to handle keypress events for password input const stdin = process.stdin; const previousRawMode = stdin.isRaw; stdin.setRawMode && stdin.setRawMode(true); await new Promise(resolve => { const keyListener = (buffer) => { const key = buffer.toString(); // Check for Enter key if (key === '\r' || key === '\n') { process.stdout.write('\n'); stdin.removeListener('data', keyListener); stdin.setRawMode && stdin.setRawMode(previousRawMode); resolve(); return; } // Check for backspace if (key === '\b' || key === '\x7f') { if (password.length > 0) { password = password.slice(0, -1); process.stdout.write('\b \b'); } return; } // Check for ctrl+c if (key === '\u0003') { console.log('\n'); process.exit(); } // Regular character password += key; process.stdout.write('*'); }; stdin.on('data', keyListener); }); console.log(chalk.blue('\nConnecting to Jira...')); const connected = await this.connect(host, username, password); if (connected) { console.log(chalk.green('\n✓ Successfully connected to Jira!')); await this.saveCredentials(host, username, password); console.log(chalk.green('✓ Credentials saved securely')); // Test fetching tickets console.log(chalk.blue('\nTesting connection by fetching your tickets...')); const ticketResult = await this.getMyTickets(); if (ticketResult.success) { console.log(chalk.green(`✓ Successfully found ${ticketResult.tickets.length} tickets assigned to you.`)); } else { console.log(chalk.yellow(`⚠️ Connected to Jira, but couldn't fetch tickets: ${ticketResult.message}`)); } } else { console.log(chalk.red('\n✗ Failed to connect to Jira. Please check your credentials.')); } return connected; } /** * Get assigned tickets from Jira */ async getMyTickets() { if (!this.isAuthenticated) { await this.initialize(); } if (!this.isAuthenticated) { return { success: false, message: 'Not authenticated with Jira' }; } try { // Get current user to find username const myself = await this.jira.getCurrentUser(); console.log('Current Jira user:', myself.name, myself.displayName); // Try multiple JQL queries to find tickets let jql = `assignee = currentUser() AND status not in (Done, Closed) ORDER BY updated DESC`; console.log('Using JQL query:', jql); let issues = await this.jira.searchJira(jql, { fields: ['summary', 'description', 'issuetype', 'priority', 'status', 'customfield_10016'], // customfield_10016 is often story points maxResults: 50 }); console.log(`Found ${issues.issues.length} tickets with first query`); // If no tickets found with first query, try alternatives if (issues.issues.length === 0) { // Try with explicit username jql = `assignee = "${myself.name}" ORDER BY updated DESC`; console.log('Trying alternative query with explicit username:', jql); issues = await this.jira.searchJira(jql, { fields: ['summary', 'description', 'issuetype', 'priority', 'status', 'customfield_10016'], maxResults: 50 }); console.log(`Found ${issues.issues.length} tickets with username query`); // If still no tickets, try with email if (issues.issues.length === 0 && myself.emailAddress) { jql = `assignee = "${myself.emailAddress}" ORDER BY updated DESC`; console.log('Trying alternative query with email:', jql); issues = await this.jira.searchJira(jql, { fields: ['summary', 'description', 'issuetype', 'priority', 'status', 'customfield_10016'], maxResults: 50 }); console.log(`Found ${issues.issues.length} tickets with email query`); } // Last attempt - all tickets (limited to 20) if (issues.issues.length === 0) { jql = `ORDER BY updated DESC`; console.log('Last attempt - fetching recent tickets:', jql); issues = await this.jira.searchJira(jql, { fields: ['summary', 'description', 'issuetype', 'priority', 'status', 'assignee', 'customfield_10016'], maxResults: 20 }); console.log(`Found ${issues.issues.length} recent tickets`); // Filter by assignee if we found tickets if (issues.issues.length > 0) { console.log('Filtering to find tickets that might be assigned to you'); // Just log the first ticket's assignee structure to understand format if (issues.issues[0]?.fields?.assignee) { console.log('Example assignee structure:', JSON.stringify(issues.issues[0].fields.assignee, null, 2)); } } } } // If we still have no tickets if (issues.issues.length === 0) { return { success: false, message: 'No tickets found assigned to you. Please check your Jira assignments.' }; } return { success: true, tickets: issues.issues.map(issue => ({ id: issue.key, name: `${issue.key}: ${issue.fields.summary}`, type: issue.fields.issuetype.name, status: issue.fields.status.name, storyPoints: issue.fields.customfield_10016 || 1, allocatedTime: this.estimateAllocatedTime(issue), jiraUrl: `https://${this.jira.host}/browse/${issue.key}` })) }; } catch (error) { console.error('Error fetching tickets:', error); return { success: false, message: error.message }; } } /** * Estimate allocated time based on story points or issue type */ estimateAllocatedTime(issue) { // If story points are available, use them (assuming 1 point = 25 minutes) const storyPoints = issue.fields.customfield_10016; if (storyPoints) { return storyPoints * 25; } // Otherwise estimate by issue type const type = issue.fields.issuetype.name.toLowerCase(); if (type.includes('bug')) { return 45; // 45 minutes for bugs } else if (type.includes('task')) { return 30; // 30 minutes for tasks } else if (type.includes('story')) { return 60; // 60 minutes for stories } // Default return 25; } /** * Update Jira ticket status */ async updateTicketStatus(ticketId, targetStatus) { if (!this.isAuthenticated) { await this.initialize(); } if (!this.isAuthenticated) { return { success: false, message: 'Not authenticated with Jira' }; } try { // Get available transitions for the issue const transitions = await this.jira.listTransitions(ticketId); console.log('Available transitions:', transitions.transitions.map(t => t.name)); // Find the transition that matches our target status const transition = transitions.transitions.find(t => t.name.toLowerCase() === targetStatus.toLowerCase() || t.to.name.toLowerCase() === targetStatus.toLowerCase() ); if (!transition) { // Try to find a similar transition if exact match not found const similarTransition = transitions.transitions.find(t => t.name.toLowerCase().includes(targetStatus.toLowerCase()) || (t.to && t.to.name.toLowerCase().includes(targetStatus.toLowerCase())) ); if (similarTransition) { // Use the similar transition const result = await this.jira.transitionIssue(ticketId, { transition: { id: similarTransition.id } }); return { success: true, message: `Ticket status updated to ${similarTransition.name} (closest match to ${targetStatus})` }; } return { success: false, message: `Cannot transition to "${targetStatus}". Available transitions: ${transitions.transitions.map(t => t.name).join(', ')}` }; } // Execute the transition await this.jira.transitionIssue(ticketId, { transition: { id: transition.id } }); return { success: true, message: `Ticket status updated to ${transition.name}` }; } catch (error) { console.error('Error updating ticket status:', error); return { success: false, message: error.message }; } } /** * Add a comment to a Jira ticket */ async addComment(ticketId, comment) { if (!this.isAuthenticated) { await this.initialize(); } if (!this.isAuthenticated) { return { success: false, message: 'Not authenticated with Jira' }; } try { await this.jira.addComment(ticketId, comment); return { success: true, message: 'Comment added successfully' }; } catch (error) { console.error('Error adding comment:', error); return { success: false, message: error.message }; } } /** * Log work on a Jira ticket */ async logWork(ticketId, timeSpentMinutes, comment = 'Logged from Ticket Hero') { if (!this.isAuthenticated) { await this.initialize(); } if (!this.isAuthenticated) { return { success: false, message: 'Not authenticated with Jira' }; } try { // Convert minutes to Jira format (e.g., "3h 30m") const hours = Math.floor(timeSpentMinutes / 60); const minutes = timeSpentMinutes % 60; let timeSpent = ''; if (hours > 0) { timeSpent += `${hours}h `; } if (minutes > 0 || timeSpent === '') { timeSpent += `${minutes}m`; } // Log the work await this.jira.addWorklog(ticketId, { timeSpent: timeSpent.trim(), comment: comment }); return { success: true, message: `Logged ${timeSpent} of work to ${ticketId}` }; } catch (error) { console.error('Error logging work:', error); return { success: false, message: error.message }; } } } module.exports = new JiraHelper();