UNPKG

taiga-mcp-server

Version:

Full-featured MCP server for Taiga project management with advanced sprint and issue tracking. Based on mcpTAIGA by adriapedralbes, enhanced with substantial modifications. Developed collaboratively with Claude Code (AI-assisted development).

1,218 lines (1,109 loc) 41.2 kB
import { createAuthenticatedClient, getAuthToken } from './taigaAuth.js'; import { API_ENDPOINTS, ERROR_MESSAGES } from './constants.js'; /** * Service for interacting with the Taiga API */ export class TaigaService { /** * Check if user is authenticated * @returns {boolean} - Whether user has valid authentication */ isAuthenticated() { try { // Check if we have the required environment variables const username = process.env.TAIGA_USERNAME; const password = process.env.TAIGA_PASSWORD; return !!(username && password); } catch (error) { return false; } } /** * Get a list of all projects the user has access to * @returns {Promise<Array>} - List of projects */ async listProjects() { try { const client = await createAuthenticatedClient(); // First get current user information const currentUser = await this.getCurrentUser(); const userId = currentUser.id; // Then get projects where user is a member const response = await client.get(API_ENDPOINTS.PROJECTS, { params: { member: userId } }); return response.data; } catch (error) { console.error('Failed to list projects:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_LIST_PROJECTS); } } /** * Get details of a specific project * @param {string} projectId - Project ID or slug * @returns {Promise<Object>} - Project details */ async getProject(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(`${API_ENDPOINTS.PROJECTS}/${projectId}`); return response.data; } catch (error) { console.error(`Failed to get project ${projectId}:`, error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_GET_PROJECT); } } /** * Get a project by its slug * @param {string} slug - Project slug * @returns {Promise<Object>} - Project details */ async getProjectBySlug(slug) { try { const client = await createAuthenticatedClient(); const response = await client.get(`/projects/by_slug?slug=${slug}`); return response.data; } catch (error) { console.error(`Failed to get project by slug ${slug}:`, error.message); throw new Error(`Failed to get project details from Taiga`); } } /** * List user stories for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of user stories */ async listUserStories(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.USER_STORIES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to list user stories for project ${projectId}:`, error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_LIST_USER_STORIES); } } /** * Create a new user story in a project * @param {Object} userStoryData - User story data * @param {string} userStoryData.project - Project ID * @param {string} userStoryData.subject - User story subject/title * @param {string} [userStoryData.description] - User story description * @param {number} [userStoryData.status] - Status ID * @param {Array} [userStoryData.tags] - Array of tags * @returns {Promise<Object>} - Created user story */ async createUserStory(userStoryData) { try { const client = await createAuthenticatedClient(); const response = await client.post(API_ENDPOINTS.USER_STORIES, userStoryData); return response.data; } catch (error) { console.error('Failed to create user story:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_CREATE_USER_STORY); } } /** * Get user story statuses for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of user story statuses */ async getUserStoryStatuses(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.USER_STORY_STATUSES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to get user story statuses for project ${projectId}:`, error.message); throw new Error('Failed to get user story statuses from Taiga'); } } /** * Get the current user's information * @returns {Promise<Object>} - User information */ async getCurrentUser() { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.USERS_ME); return response.data; } catch (error) { console.error('Failed to get current user:', error.message); throw new Error('Failed to get user information from Taiga'); } } /** * Create a new task associated with a user story * @param {Object} taskData - Task data * @param {string} taskData.project - Project ID * @param {string} taskData.subject - Task subject/title * @param {string} [taskData.description] - Task description * @param {string} [taskData.user_story] - User story ID * @param {string} [taskData.status] - Status ID * @param {Array} [taskData.tags] - Array of tags * @returns {Promise<Object>} - Created task */ async createTask(taskData) { try { const client = await createAuthenticatedClient(); const response = await client.post(API_ENDPOINTS.TASKS, taskData); return response.data; } catch (error) { console.error('Failed to create task:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_CREATE_TASK); } } /** * Get task statuses for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of task statuses */ async getTaskStatuses(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.TASK_STATUSES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to get task statuses for project ${projectId}:`, error.message); throw new Error('Failed to get task statuses from Taiga'); } } /** * List issues for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of issues */ async listIssues(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.ISSUES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to list issues for project ${projectId}:`, error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_LIST_ISSUES); } } /** * Create a new issue in a project * @param {Object} issueData - Issue data * @param {string} issueData.project - Project ID * @param {string} issueData.subject - Issue subject/title * @param {string} [issueData.description] - Issue description * @param {number} [issueData.status] - Status ID * @param {number} [issueData.priority] - Priority ID * @param {number} [issueData.severity] - Severity ID * @param {number} [issueData.type] - Issue type ID * @param {number} [issueData.assigned_to] - Assigned user ID * @param {Array} [issueData.tags] - Array of tags * @returns {Promise<Object>} - Created issue */ async createIssue(issueData) { try { const client = await createAuthenticatedClient(); const response = await client.post(API_ENDPOINTS.ISSUES, issueData); return response.data; } catch (error) { console.error('Failed to create issue:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_CREATE_ISSUE); } } /** * Get issue statuses for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of issue statuses */ async getIssueStatuses(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.ISSUE_STATUSES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to get issue statuses for project ${projectId}:`, error.message); throw new Error('Failed to get issue statuses from Taiga'); } } /** * Get issue priorities for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of issue priorities */ async getIssuePriorities(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.PRIORITIES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to get issue priorities for project ${projectId}:`, error.message); throw new Error('Failed to get issue priorities from Taiga'); } } /** * Get issue severities for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of issue severities */ async getIssueSeverities(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.SEVERITIES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to get issue severities for project ${projectId}:`, error.message); throw new Error('Failed to get issue severities from Taiga'); } } /** * Get issue types for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of issue types */ async getIssueTypes(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.ISSUE_TYPES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to get issue types for project ${projectId}:`, error.message); throw new Error('Failed to get issue types from Taiga'); } } /** * Get project members (for issue assignment) * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of project members */ async getProjectMembers(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.MEMBERSHIPS, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to get project members for project ${projectId}:`, error.message); throw new Error('Failed to get project members from Taiga'); } } /** * Update an issue * @param {number} issueId - Issue ID * @param {Object} updateData - Data to update * @returns {Promise<Object>} - Updated issue */ async updateIssue(issueId, updateData) { try { const client = await createAuthenticatedClient(); // Get current issue to get version for update const currentIssue = await this.getIssue(issueId); const dataWithVersion = { ...updateData, version: currentIssue.version }; const response = await client.patch(`${API_ENDPOINTS.ISSUES}/${issueId}`, dataWithVersion); return response.data; } catch (error) { console.error('Failed to update issue:', error.message); throw new Error('Failed to update issue in Taiga'); } } /** * List milestones (sprints) for a project * @param {string} projectId - Project ID * @returns {Promise<Array>} - List of milestones */ async listMilestones(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.MILESTONES, { params: { project: projectId } }); return response.data; } catch (error) { console.error(`Failed to list milestones for project ${projectId}:`, error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_LIST_MILESTONES); } } /** * Get details of a specific milestone (sprint) * @param {string} milestoneId - Milestone ID * @returns {Promise<Object>} - Milestone details */ async getMilestone(milestoneId) { try { const client = await createAuthenticatedClient(); const response = await client.get(`${API_ENDPOINTS.MILESTONES}/${milestoneId}`); return response.data; } catch (error) { console.error(`Failed to get milestone ${milestoneId}:`, error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_GET_MILESTONE); } } /** * Get statistics for a specific milestone (sprint) * @param {string} milestoneId - Milestone ID * @returns {Promise<Object>} - Milestone statistics */ async getMilestoneStats(milestoneId) { try { const client = await createAuthenticatedClient(); const response = await client.get(`${API_ENDPOINTS.MILESTONES}/${milestoneId}/stats`); return response.data; } catch (error) { console.error(`Failed to get milestone stats for ${milestoneId}:`, error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_GET_MILESTONE_STATS); } } /** * Get details of a specific issue * @param {string} issueId - Issue ID * @returns {Promise<Object>} - Issue details */ async getIssue(issueId) { try { const client = await createAuthenticatedClient(); const url = `${API_ENDPOINTS.ISSUES}/${issueId}`; const response = await client.get(url); return response.data; } catch (error) { console.error(`Failed to get issue ${issueId}:`, error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_GET_ISSUE); } } /** * Get issue by reference number * @param {string} ref - Issue reference number * @param {string} projectId - Project ID * @returns {Promise<Object>} - Issue details */ async getIssueByRef(ref, projectId) { try { const client = await createAuthenticatedClient(); const url = `${API_ENDPOINTS.ISSUES}/by_ref`; const params = { ref, project: projectId }; const response = await client.get(url, { params }); return response.data; } catch (error) { console.error(`Failed to get issue by ref ${ref}:`, error.message); throw new Error('Failed to get issue by reference from Taiga'); } } /** * List issues filtered by milestone (sprint) * @param {string} projectId - Project ID * @param {string} milestoneId - Milestone ID * @returns {Promise<Array>} - List of issues in the milestone */ async getIssuesByMilestone(projectId, milestoneId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.ISSUES, { params: { project: projectId, milestone: milestoneId } }); return response.data; } catch (error) { console.error(`Failed to get issues for milestone ${milestoneId}:`, error.message); throw new Error('Failed to get issues by milestone from Taiga'); } } /** * Create a new milestone (sprint) in a project * @param {Object} milestoneData - Milestone data * @param {string} milestoneData.project - Project ID * @param {string} milestoneData.name - Milestone name * @param {string} [milestoneData.estimated_start] - Estimated start date (YYYY-MM-DD) * @param {string} [milestoneData.estimated_finish] - Estimated finish date (YYYY-MM-DD) * @param {boolean} [milestoneData.disponibility] - Availability percentage * @returns {Promise<Object>} - Created milestone */ async createMilestone(milestoneData) { try { const client = await createAuthenticatedClient(); const response = await client.post(API_ENDPOINTS.MILESTONES, milestoneData); return response.data; } catch (error) { console.error('Failed to create milestone:', error.message); throw new Error('Failed to create milestone in Taiga'); } } /** * Add comment to an item (issue, user story, or task) * @param {string} itemType - Type of item ('issue', 'user_story', 'task') * @param {number} itemId - ID of the item * @param {Object} commentData - Comment data * @returns {Promise<Object>} - Created comment */ async addComment(itemType, itemId, commentData) { try { const client = await createAuthenticatedClient(); // 首先获取当前项目版本(必需用于版本控制) const currentVersion = await this.getItemVersion(itemType, itemId); // Taiga使用歷史API來處理評論 // 通過更新項目並添加評論來創建評論記錄 const endpoint = this.getItemEndpoint(itemType); const updateData = { comment: commentData.comment, version: currentVersion }; const response = await client.patch(`${endpoint}/${itemId}`, updateData); return response.data; } catch (error) { // 提供更具体的错误信息 if (error.response?.status === 400) { const errorMsg = error.response?.data?.version ? 'Version parameter is invalid. The item may have been modified by another user.' : 'Bad request. Please check the comment data format.'; throw new Error(`Failed to add comment to Taiga: ${errorMsg}`); } else if (error.response?.status === 403) { throw new Error('Failed to add comment to Taiga: Permission denied. Check your access rights.'); } else if (error.response?.status === 404) { throw new Error(`Failed to add comment to Taiga: ${itemType} #${itemId} not found.`); } else { throw new Error(`Failed to add comment to Taiga: ${error.message}`); } } } /** * Get history/comments for an item * @param {string} itemType - Type of item ('issue', 'user_story', 'task') * @param {number} itemId - ID of the item * @returns {Promise<Array>} - History entries including comments */ async getItemHistory(itemType, itemId) { try { const client = await createAuthenticatedClient(); const objectType = this.getHistoryObjectType(itemType); const response = await client.get(`${API_ENDPOINTS.HISTORY}/${objectType}/${itemId}`); return response.data; } catch (error) { console.error('Failed to get item history:', error.message); throw new Error('Failed to get item history from Taiga'); } } /** * Edit a comment * @param {number} commentId - ID of the comment * @param {string} newComment - New comment content * @returns {Promise<Object>} - Updated comment */ async editComment(commentId, newComment) { try { const client = await createAuthenticatedClient(); const response = await client.patch(`${API_ENDPOINTS.HISTORY}/edit-comment`, { id: commentId, comment: newComment }); return response.data; } catch (error) { console.error('Failed to edit comment:', error.message); throw new Error('Failed to edit comment in Taiga'); } } /** * Delete a comment * @param {number} commentId - ID of the comment to delete * @returns {Promise<void>} */ async deleteComment(commentId) { try { const client = await createAuthenticatedClient(); await client.delete(`${API_ENDPOINTS.HISTORY}/delete-comment`, { data: { id: commentId } }); } catch (error) { console.error('Failed to delete comment:', error.message); throw new Error('Failed to delete comment from Taiga'); } } /** * Get current user ID * @returns {Promise<number>} - Current user ID */ async getCurrentUserId() { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.USERS_ME); return response.data.id; } catch (error) { console.error('Failed to get current user:', error.message); throw new Error('Failed to get current user from Taiga'); } } /** * Get item endpoint based on type * @private */ getItemEndpoint(itemType) { const endpoints = { 'issue': API_ENDPOINTS.ISSUES, 'user_story': API_ENDPOINTS.USER_STORIES, 'task': API_ENDPOINTS.TASKS }; return endpoints[itemType] || API_ENDPOINTS.ISSUES; } /** * Get history object type based on item type * @private */ getHistoryObjectType(itemType) { const types = { 'issue': 'issue', 'user_story': 'userstory', 'task': 'task' }; return types[itemType] || 'issue'; } /** * Get current version of an item (needed for comment updates) * @private */ async getItemVersion(itemType, itemId) { try { const client = await createAuthenticatedClient(); const endpoint = this.getItemEndpoint(itemType); const response = await client.get(`${endpoint}/${itemId}`); const version = response.data.version; if (typeof version !== 'number') { return 1; // 默認版本 } return version; } catch (error) { if (error.response?.status === 404) { throw new Error(`${itemType} #${itemId} not found`); } else if (error.response?.status === 403) { throw new Error(`Permission denied accessing ${itemType} #${itemId}`); } else { throw new Error(`Failed to get ${itemType} version: ${error.message}`); } } } /** * Upload attachment to an item (issue, user story, or task) * @param {string} itemType - Type of item ('issue', 'user_story', 'task') * @param {number} itemId - ID of the item * @param {string} fileData - Base64 encoded file data * @param {string} fileName - Original file name * @param {string} [mimeType] - MIME type of the file * @param {string} [description] - Optional description for the attachment * @returns {Promise<Object>} - Created attachment */ async uploadAttachment(itemType, itemId, fileData, fileName, mimeType, description) { try { // Import required modules const FormData = (await import('form-data')).default; // Get authenticated client to ensure we have a valid token const client = await createAuthenticatedClient(); const token = await getAuthToken(); // Get attachment endpoint based on item type const endpoint = this.getAttachmentEndpoint(itemType); // Validate required parameters if (!fileData || typeof fileData !== 'string') { throw new Error(`Invalid file data: fileData must be a base64 encoded string`); } if (!fileName || typeof fileName !== 'string') { throw new Error(`Invalid file name: fileName is required`); } // Convert base64 to buffer let fileBuffer; try { // Remove data URI prefix if present (e.g., "data:image/jpeg;base64,") const base64Data = fileData.includes(',') ? fileData.split(',')[1] : fileData; fileBuffer = Buffer.from(base64Data, 'base64'); } catch (error) { throw new Error(`Invalid base64 data: ${error.message}`); } // Create FormData instance const formData = new FormData(); formData.append('object_id', itemId.toString()); // Append file buffer - 使用更簡單的格式 // 某些版本的 form-data 可能對第三個參數格式敏感 formData.append('attached_file', fileBuffer, fileName); if (description) { formData.append('description', description); } // 嘗試添加項目ID(可能是必需的) try { const itemData = await this.getItemData(itemType, itemId); if (itemData && itemData.project) { formData.append('project', itemData.project.toString()); } } catch (projectError) { // 靜默忽略項目ID獲取失敗,繼續上傳 } // Use axios with FormData (client already has auth headers) const response = await client.post(endpoint, formData, { headers: { ...formData.getHeaders() // Don't add Authorization header - client already has it }, maxContentLength: Infinity, maxBodyLength: Infinity, }); return response.data; } catch (error) { // Provide more specific error information if (error.message.includes('Invalid base64')) { throw new Error(`Invalid file data format: ${error.message}`); } else if (error.message.includes('cb is not a function')) { console.error('Callback error detected - this might be a form-data compatibility issue'); throw new Error('Upload failed due to form-data callback issue'); } else if (error.response?.status === 413) { throw new Error('File too large for upload'); } else if (error.response?.status === 401) { throw new Error('Authentication failed - please check credentials'); } else if (error.response?.status === 404) { throw new Error('Upload endpoint not found - check item ID and type'); } throw new Error(`Failed to upload attachment to Taiga: ${error.message}`); } } /** * Upload attachment from file path (legacy method for backward compatibility) * @param {string} itemType - Type of item ('issue', 'user_story', 'task') * @param {number} itemId - ID of the item * @param {string} filePath - Path to the file to upload * @param {string} [description] - Optional description for the attachment * @returns {Promise<Object>} - Created attachment */ async uploadAttachmentFromPath(itemType, itemId, filePath, description) { try { // Import required modules const fs = await import('fs'); const path = await import('path'); // Validate filePath parameter if (!filePath || typeof filePath !== 'string') { throw new Error(`Invalid file path: ${filePath}`); } // Check if file exists - try multiple path resolution strategies let absolutePath; let fileExists = false; if (path.default.isAbsolute(filePath)) { // If already absolute, use as-is absolutePath = filePath; fileExists = fs.default.existsSync(absolutePath); } else { // For relative paths, try different resolution strategies const os = await import('os'); const homeDir = process.env.HOME || process.env.USERPROFILE || os.default.homedir(); const possiblePaths = [ // 1. Relative to current working directory path.default.resolve(process.cwd(), filePath), // 2. Relative to user's home directory path.default.resolve(homeDir, filePath), // 3. Relative to Desktop (common location for downloaded files) path.default.resolve(homeDir, 'Desktop', filePath), // 4. Relative to Downloads (another common location) path.default.resolve(homeDir, 'Downloads', filePath) ]; for (const tryPath of possiblePaths) { if (fs.default.existsSync(tryPath)) { absolutePath = tryPath; fileExists = true; break; } } } if (!fileExists) { const os = await import('os'); const homeDir = process.env.HOME || process.env.USERPROFILE || os.default.homedir(); const searchedPaths = path.default.isAbsolute(filePath) ? [filePath] : [ path.default.resolve(process.cwd(), filePath), path.default.resolve(homeDir, filePath), path.default.resolve(homeDir, 'Desktop', filePath), path.default.resolve(homeDir, 'Downloads', filePath) ]; throw new Error(`File not found. Searched paths:\n${searchedPaths.join('\n')}`); } // Convert file to base64 and use the new method const fileBuffer = fs.default.readFileSync(absolutePath); const fileData = fileBuffer.toString('base64'); const fileName = path.default.basename(absolutePath); // Detect MIME type const mimeType = this.detectMimeType(fileName); // Use the new base64 upload method return await this.uploadAttachment(itemType, itemId, fileData, fileName, mimeType, description); } catch (error) { throw new Error(`Failed to upload attachment from path: ${error.message}`); } } /** * Detect MIME type from file extension * @param {string} fileName - File name * @returns {string} - MIME type */ detectMimeType(fileName) { const ext = fileName.toLowerCase().split('.').pop(); const mimeTypes = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'pdf': 'application/pdf', 'txt': 'text/plain', 'doc': 'application/msword', 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'xls': 'application/vnd.ms-excel', 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'zip': 'application/zip', 'mp4': 'video/mp4', 'mp3': 'audio/mpeg' }; return mimeTypes[ext] || 'application/octet-stream'; } /** * List attachments for an item * @param {string} itemType - Type of item ('issue', 'user_story', 'task') * @param {number} itemId - ID of the item * @returns {Promise<Array>} - List of attachments */ async listAttachments(itemType, itemId) { try { const client = await createAuthenticatedClient(); const endpoint = this.getAttachmentEndpoint(itemType); const response = await client.get(endpoint, { params: { object_id: itemId } }); return response.data; } catch (error) { console.error('Failed to list attachments:', error.message); throw new Error('Failed to list attachments from Taiga'); } } /** * Download attachment by ID * @param {number} attachmentId - ID of the attachment * @param {string} [downloadPath] - Optional path to save the file * @returns {Promise<Object>} - Download result with filename and path */ async downloadAttachment(attachmentId, downloadPath) { try { const client = await createAuthenticatedClient(); const fs = await import('fs'); const path = await import('path'); // First get attachment details const attachmentResponse = await client.get(`${API_ENDPOINTS.ISSUE_ATTACHMENTS}/${attachmentId}`); const attachment = attachmentResponse.data; // Download the file const fileResponse = await client.get(attachment.url, { responseType: 'stream' }); // Determine save path const filename = attachment.name || `attachment_${attachmentId}`; const savedPath = downloadPath || path.default.join(process.cwd(), filename); // Save file const writer = fs.default.createWriteStream(savedPath); fileResponse.data.pipe(writer); return new Promise((resolve, reject) => { writer.on('finish', () => { resolve({ filename, savedPath, size: attachment.size }); }); writer.on('error', reject); }); } catch (error) { console.error('Failed to download attachment:', error.message); throw new Error('Failed to download attachment from Taiga'); } } /** * Delete attachment by ID * @param {number} attachmentId - ID of the attachment to delete * @returns {Promise<void>} */ async deleteAttachment(attachmentId) { try { const client = await createAuthenticatedClient(); await client.delete(`${API_ENDPOINTS.ISSUE_ATTACHMENTS}/${attachmentId}`); } catch (error) { console.error('Failed to delete attachment:', error.message); throw new Error('Failed to delete attachment from Taiga'); } } /** * Get item data (to extract project info) * @private */ async getItemData(itemType, itemId) { const client = await createAuthenticatedClient(); const endpoint = this.getItemEndpoint(itemType); const response = await client.get(`${endpoint}/${itemId}`); return response.data; } /** * Get attachment endpoint based on item type * @private */ getAttachmentEndpoint(itemType) { const endpoints = { 'issue': API_ENDPOINTS.ISSUE_ATTACHMENTS, 'user_story': API_ENDPOINTS.USERSTORY_ATTACHMENTS, 'task': API_ENDPOINTS.TASK_ATTACHMENTS }; return endpoints[itemType] || API_ENDPOINTS.ISSUE_ATTACHMENTS; } /** * Create a new Epic * @param {Object} epicData - Epic data * @returns {Promise<Object>} - Created Epic */ async createEpic(epicData) { try { const client = await createAuthenticatedClient(); const response = await client.post(API_ENDPOINTS.EPICS, epicData); return response.data; } catch (error) { console.error('Failed to create epic:', error.message); throw new Error('Failed to create epic in Taiga'); } } /** * List all Epics in a project * @param {number} projectId - Project ID * @returns {Promise<Array>} - List of Epics */ async listEpics(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.EPICS, { params: { project: projectId } }); return response.data; } catch (error) { console.error('Failed to list epics:', error.message); throw new Error('Failed to list epics from Taiga'); } } /** * Get Epic details by ID * @param {number} epicId - Epic ID * @returns {Promise<Object>} - Epic details */ async getEpic(epicId) { try { const client = await createAuthenticatedClient(); const response = await client.get(`${API_ENDPOINTS.EPICS}/${epicId}`); return response.data; } catch (error) { console.error('Failed to get epic:', error.message); throw new Error('Failed to get epic details from Taiga'); } } /** * Update Epic * @param {number} epicId - Epic ID * @param {Object} updateData - Data to update * @returns {Promise<Object>} - Updated Epic */ async updateEpic(epicId, updateData) { try { const client = await createAuthenticatedClient(); // Get current Epic to get version for update const currentEpic = await this.getEpic(epicId); const dataWithVersion = { ...updateData, version: currentEpic.version }; const response = await client.patch(`${API_ENDPOINTS.EPICS}/${epicId}`, dataWithVersion); return response.data; } catch (error) { console.error('Failed to update epic:', error.message); throw new Error('Failed to update epic in Taiga'); } } /** * Link User Story to Epic * @param {number} userStoryId - User Story ID * @param {number} epicId - Epic ID * @returns {Promise<Object>} - Updated User Story */ async linkStoryToEpic(userStoryId, epicId) { try { const client = await createAuthenticatedClient(); // Get current User Story to get version for update const currentStory = await client.get(`${API_ENDPOINTS.USER_STORIES}/${userStoryId}`); const updateData = { epic: epicId, version: currentStory.data.version }; const response = await client.patch(`${API_ENDPOINTS.USER_STORIES}/${userStoryId}`, updateData); return response.data; } catch (error) { console.error('Failed to link story to epic:', error.message); throw new Error('Failed to link user story to epic'); } } /** * Unlink User Story from Epic * @param {number} userStoryId - User Story ID * @returns {Promise<Object>} - Updated User Story */ async unlinkStoryFromEpic(userStoryId) { try { const client = await createAuthenticatedClient(); // Get current User Story to get version for update const currentStory = await client.get(`${API_ENDPOINTS.USER_STORIES}/${userStoryId}`); const updateData = { epic: null, version: currentStory.data.version }; const response = await client.patch(`${API_ENDPOINTS.USER_STORIES}/${userStoryId}`, updateData); return response.data; } catch (error) { console.error('Failed to unlink story from epic:', error.message); throw new Error('Failed to unlink user story from epic'); } } // ========================= WIKI MANAGEMENT ========================= /** * Create a new Wiki page * @param {Object} wikiData - Wiki page data * @returns {Promise<Object>} - Created wiki page */ async createWikiPage(wikiData) { try { const client = await createAuthenticatedClient(); const response = await client.post(API_ENDPOINTS.WIKI, wikiData); return response.data; } catch (error) { console.error('Failed to create wiki page:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_CREATE_WIKI); } } /** * List all Wiki pages in a project * @param {number} projectId - Project ID * @returns {Promise<Array>} - List of wiki pages */ async listWikiPages(projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(API_ENDPOINTS.WIKI, { params: { project: projectId } }); return response.data; } catch (error) { console.error('Failed to list wiki pages:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_LIST_WIKI); } } /** * Get Wiki page by ID * @param {number} wikiPageId - Wiki page ID * @returns {Promise<Object>} - Wiki page details */ async getWikiPage(wikiPageId) { try { const client = await createAuthenticatedClient(); const response = await client.get(`${API_ENDPOINTS.WIKI}/${wikiPageId}`); return response.data; } catch (error) { console.error('Failed to get wiki page:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_GET_WIKI); } } /** * Get Wiki page by slug * @param {string} slug - Wiki page slug * @param {number} projectId - Project ID * @returns {Promise<Object>} - Wiki page details */ async getWikiPageBySlug(slug, projectId) { try { const client = await createAuthenticatedClient(); const response = await client.get(`${API_ENDPOINTS.WIKI}/by_slug`, { params: { slug: slug, project: projectId } }); return response.data; } catch (error) { console.error('Failed to get wiki page by slug:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_GET_WIKI); } } /** * Update Wiki page * @param {number} wikiPageId - Wiki page ID * @param {Object} updateData - Update data * @returns {Promise<Object>} - Updated wiki page */ async updateWikiPage(wikiPageId, updateData) { try { const client = await createAuthenticatedClient(); // Get current wiki page to get version for update const currentWiki = await client.get(`${API_ENDPOINTS.WIKI}/${wikiPageId}`); const dataWithVersion = { ...updateData, version: currentWiki.data.version }; const response = await client.patch(`${API_ENDPOINTS.WIKI}/${wikiPageId}`, dataWithVersion); return response.data; } catch (error) { console.error('Failed to update wiki page:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_UPDATE_WIKI); } } /** * Delete Wiki page * @param {number} wikiPageId - Wiki page ID * @returns {Promise<void>} */ async deleteWikiPage(wikiPageId) { try { const client = await createAuthenticatedClient(); await client.delete(`${API_ENDPOINTS.WIKI}/${wikiPageId}`); } catch (error) { console.error('Failed to delete wiki page:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_DELETE_WIKI); } } /** * Watch/Unwatch Wiki page * @param {number} wikiPageId - Wiki page ID * @param {boolean} watch - True to watch, false to unwatch * @returns {Promise<Object>} - Response data */ async watchWikiPage(wikiPageId, watch = true) { try { const client = await createAuthenticatedClient(); const endpoint = watch ? `${API_ENDPOINTS.WIKI}/${wikiPageId}/watch` : `${API_ENDPOINTS.WIKI}/${wikiPageId}/unwatch`; const response = await client.post(endpoint); return response.data; } catch (error) { console.error('Failed to watch/unwatch wiki page:', error.message); throw new Error(ERROR_MESSAGES.FAILED_TO_WATCH_WIKI); } } }