├── .nvmrc ├── TODO.md ├── .gitignore ├── user-mapping.example.js ├── user-mapping.js ├── .env.example ├── package.json ├── LICENSE ├── list-users.js ├── migrate-relationships.js ├── delete-relationships.js ├── migrate-parents.js ├── generate-user-mapping.js ├── README.md ├── migrate.js ├── remove-duplicates.js ├── jira-client.js ├── create-relationships.js ├── openproject-client.js └── index.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.14.0 2 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - [ ] improve logging with colors and progress bars -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env 3 | 4 | # Dependencies 5 | node_modules/ 6 | 7 | # Temporary files 8 | temp/ 9 | *.log 10 | 11 | # IDE files 12 | .vscode/ 13 | .idea/ 14 | *.swp 15 | *.swo 16 | 17 | # OS files 18 | .DS_Store 19 | Thumbs.db 20 | -------------------------------------------------------------------------------- /user-mapping.example.js: -------------------------------------------------------------------------------- 1 | // Example user mapping - replace with your own mappings 2 | const userMapping = { 3 | "jira-user-account-id-1": "openproject-user-id-1", 4 | "jira-user-account-id-2": "openproject-user-id-2", 5 | }; 6 | 7 | module.exports = userMapping; 8 | -------------------------------------------------------------------------------- /user-mapping.js: -------------------------------------------------------------------------------- 1 | // Generated user mapping - 2025-01-13T18:04:37.708Z 2 | const userMapping = { 3 | "712020:a501b647-cd9d-45b5-87c4-f895edcd2701": 9, 4 | "712020:a69fec6d-e877-4093-bf26-10e322b01005": 11, 5 | "712020:96ef5dde-61e6-487e-b4ca-2bea43b238c8": 12, 6 | "712020:c591333e-ae00-4be7-8831-9203430add9d": 10, 7 | "712020:0861a7a7-de8e-424f-a6b1-6f44f926e462": 16, 8 | "712020:58d0274a-6a5d-44c6-9b6c-400c264164fe": 14, 9 | "70121:54b30cd9-ee84-40e3-b8b3-7f258cf323f3": 15, 10 | "712020:4d3c7bf3-ca8e-4d1e-b572-0f140f4b7b9f": 13 11 | }; 12 | 13 | module.exports = userMapping; 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Jira Configuration 2 | JIRA_HOST=your-domain.atlassian.net 3 | JIRA_EMAIL=your-email@example.com 4 | JIRA_API_TOKEN=your-jira-api-token 5 | 6 | # OpenProject Configuration 7 | OPENPROJECT_HOST=https://your-openproject-instance.com 8 | OPENPROJECT_API_KEY=your-openproject-api-key 9 | # Custom Field Configuration 10 | # The ID of the custom field in OpenProject that stores the Jira issue ID 11 | # You can find this ID in OpenProject: Administration > Custom fields > Work packages 12 | # Look for a text custom field that will store the Jira issue key 13 | # Default: 1 14 | JIRA_ID_CUSTOM_FIELD=1 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira-to-openproject-migrator", 3 | "version": "1.0.0", 4 | "description": "A tool to migrate issues from Jira to OpenProject, including attachments, comments, and relationships", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node index.js", 9 | "generate-mapping": "node generate-user-mapping.js", 10 | "migrate-parents": "node migrate-parents.js", 11 | "migrate-relationships": "node migrate-relationships.js", 12 | "remove-duplicates": "node remove-duplicates.js" 13 | }, 14 | "keywords": [ 15 | "jira", 16 | "openproject", 17 | "migration", 18 | "issue-tracker", 19 | "project-management" 20 | ], 21 | "author": "", 22 | "license": "MIT", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/yourusername/jira-to-openproject-migrator" 26 | }, 27 | "dependencies": { 28 | "axios": "^1.7.9", 29 | "dotenv": "^16.4.7", 30 | "form-data": "^4.0.1", 31 | "inquirer": "^8.2.6", 32 | "node-html-parser": "^7.0.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 EliteCoders 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /list-users.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const axios = require("axios"); 3 | 4 | // Jira API configuration 5 | const jiraConfig = { 6 | baseURL: `https://${process.env.JIRA_HOST}/rest/api/3`, 7 | auth: { 8 | username: process.env.JIRA_EMAIL, 9 | password: process.env.JIRA_API_TOKEN, 10 | }, 11 | }; 12 | 13 | // OpenProject API configuration 14 | const openProjectConfig = { 15 | baseURL: `${process.env.OPENPROJECT_HOST}/api/v3`, 16 | headers: { 17 | Authorization: `Basic ${Buffer.from( 18 | `apikey:${process.env.OPENPROJECT_API_KEY}` 19 | ).toString("base64")}`, 20 | "Content-Type": "application/json", 21 | }, 22 | }; 23 | 24 | const jiraApi = axios.create(jiraConfig); 25 | const openProjectApi = axios.create(openProjectConfig); 26 | 27 | async function getJiraUsers() { 28 | try { 29 | const response = await jiraApi.get("/users/search", { 30 | params: { 31 | maxResults: 1000, 32 | }, 33 | }); 34 | console.log("\nJira Users:"); 35 | console.log("==========="); 36 | response.data.forEach((user) => { 37 | console.log(`${user.displayName} (AccountId: ${user.accountId})`); 38 | }); 39 | } catch (error) { 40 | console.error("Error fetching Jira users:", error.message); 41 | } 42 | } 43 | 44 | async function getOpenProjectUsers() { 45 | try { 46 | const response = await openProjectApi.get("/users"); 47 | console.log("\nOpenProject Users:"); 48 | console.log("================="); 49 | response.data._embedded.elements.forEach((user) => { 50 | console.log(`${user.name} (ID: ${user.id}, Email: ${user.email})`); 51 | }); 52 | } catch (error) { 53 | console.error("Error fetching OpenProject users:", error.message); 54 | } 55 | } 56 | 57 | async function main() { 58 | await getJiraUsers(); 59 | await getOpenProjectUsers(); 60 | } 61 | 62 | main(); 63 | -------------------------------------------------------------------------------- /migrate-relationships.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const { createRelationships } = require("./create-relationships"); 3 | const { getAllJiraIssues, getSpecificJiraIssues } = require("./jira-client"); 4 | const { 5 | getOpenProjectWorkPackages: getOpenProjectWorkPackagesFromClient, 6 | } = require("./openproject-client"); 7 | 8 | // OpenProject API configuration 9 | const openProjectConfig = { 10 | baseURL: `${process.env.OPENPROJECT_HOST}/api/v3`, 11 | headers: { 12 | Authorization: `Basic ${Buffer.from( 13 | `apikey:${process.env.OPENPROJECT_API_KEY}` 14 | ).toString("base64")}`, 15 | "Content-Type": "application/json", 16 | }, 17 | }; 18 | 19 | async function getOpenProjectWorkPackages(projectId) { 20 | try { 21 | console.log(`Fetching work packages for project ${projectId}...`); 22 | const workPackagesMap = await getOpenProjectWorkPackagesFromClient( 23 | projectId 24 | ); 25 | 26 | // Convert Map to simple object format 27 | const mapping = {}; 28 | for (const [jiraId, wp] of workPackagesMap.entries()) { 29 | mapping[jiraId] = wp.id; 30 | } 31 | 32 | return mapping; 33 | } catch (error) { 34 | console.error("Error fetching OpenProject work packages:", error.message); 35 | throw error; 36 | } 37 | } 38 | 39 | async function migrateRelationships( 40 | jiraProjectKey, 41 | openProjectId, 42 | specificIssues = null 43 | ) { 44 | try { 45 | console.log("\n=== Starting Relationship Migration ==="); 46 | 47 | // Get the mapping of Jira keys to OpenProject IDs 48 | const mapping = await getOpenProjectWorkPackages(openProjectId); 49 | console.log(`Found ${Object.keys(mapping).length} mapped work packages`); 50 | 51 | // Get Jira issues with their relationships 52 | const issues = specificIssues 53 | ? await getSpecificJiraIssues(jiraProjectKey, specificIssues) 54 | : await getAllJiraIssues(jiraProjectKey); 55 | console.log(`Found ${issues.length} Jira issues to process`); 56 | 57 | // Create relationships 58 | await createRelationships(issues, mapping); 59 | } catch (error) { 60 | console.error("Migration failed:", error.message); 61 | } 62 | } 63 | 64 | // Parse command line arguments 65 | const args = process.argv.slice(2); 66 | const jiraProjectKey = args[0]; 67 | const openProjectId = args[1]; 68 | const specificIssues = args[2] ? args[2].split(",") : null; 69 | 70 | if (!jiraProjectKey || !openProjectId) { 71 | console.log( 72 | "Usage: node migrate-relationships.js JIRA_PROJECT_KEY OPENPROJECT_ID [ISSUE1,ISSUE2,...]" 73 | ); 74 | console.log("Example: node migrate-relationships.js CLD 9"); 75 | console.log( 76 | "Example with specific issues: node migrate-relationships.js CLD 9 CLD-123,CLD-124" 77 | ); 78 | console.log( 79 | "Note: Use 'all' as OPENPROJECT_ID to include all projects (useful for cross-project links)" 80 | ); 81 | process.exit(1); 82 | } 83 | 84 | migrateRelationships(jiraProjectKey, openProjectId, specificIssues); 85 | -------------------------------------------------------------------------------- /delete-relationships.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const axios = require("axios"); 3 | const { 4 | getOpenProjectWorkPackages, 5 | setParentWorkPackage, 6 | listProjects, 7 | } = require("./openproject-client"); 8 | 9 | // OpenProject API configuration 10 | const openProjectConfig = { 11 | baseURL: `${process.env.OPENPROJECT_HOST}/api/v3`, 12 | headers: { 13 | Authorization: `Basic ${Buffer.from( 14 | `apikey:${process.env.OPENPROJECT_API_KEY}` 15 | ).toString("base64")}`, 16 | "Content-Type": "application/json", 17 | }, 18 | }; 19 | 20 | const openProjectApi = axios.create(openProjectConfig); 21 | 22 | async function deleteRelationship(relationId) { 23 | try { 24 | await openProjectApi.delete(`/relations/${relationId}`); 25 | console.log(`Deleted relationship ${relationId}`); 26 | } catch (error) { 27 | console.error(`Error deleting relationship ${relationId}:`, error.message); 28 | if (error.response?.data) { 29 | console.error( 30 | "Error details:", 31 | JSON.stringify(error.response.data, null, 2) 32 | ); 33 | } 34 | } 35 | } 36 | 37 | async function clearParentRelationship(workPackageId) { 38 | try { 39 | // Use setParentWorkPackage with null parent to clear 40 | await setParentWorkPackage(workPackageId, null); 41 | console.log( 42 | `Cleared parent relationship for work package ${workPackageId}` 43 | ); 44 | } catch (error) { 45 | console.error( 46 | `Error clearing parent for work package ${workPackageId}:`, 47 | error.message 48 | ); 49 | } 50 | } 51 | 52 | async function deleteAllRelationships(projectId) { 53 | try { 54 | console.log("\n=== Starting Relationship Deletion ==="); 55 | 56 | // List available projects 57 | await listProjects(); 58 | 59 | // Get all work packages using the helper 60 | const workPackagesMap = await getOpenProjectWorkPackages(projectId); 61 | const workPackages = Array.from(workPackagesMap.values()); 62 | console.log(`\nFound ${workPackages.length} work packages to process`); 63 | 64 | // Track statistics 65 | let relationshipsDeleted = 0; 66 | let parentsCleared = 0; 67 | 68 | // Process each work package 69 | for (const wp of workPackages) { 70 | // Clear parent relationship if it exists 71 | if (wp._links.parent?.href) { 72 | await clearParentRelationship(wp.id); 73 | parentsCleared++; 74 | } 75 | 76 | // Get and delete all relationships 77 | try { 78 | const relationsResponse = await openProjectApi.get( 79 | `/work_packages/${wp.id}/relations` 80 | ); 81 | const relations = relationsResponse.data._embedded?.elements || []; 82 | 83 | for (const relation of relations) { 84 | await deleteRelationship(relation.id); 85 | relationshipsDeleted++; 86 | } 87 | } catch (error) { 88 | console.error( 89 | `Error processing relations for work package ${wp.id}:`, 90 | error.message 91 | ); 92 | } 93 | } 94 | 95 | console.log("\n=== Deletion Summary ==="); 96 | console.log(`Total work packages processed: ${workPackages.length}`); 97 | console.log(`Parent relationships cleared: ${parentsCleared}`); 98 | console.log(`Other relationships deleted: ${relationshipsDeleted}`); 99 | console.log("=== Deletion Complete ===\n"); 100 | } catch (error) { 101 | console.error("\nDeletion failed:", error.message); 102 | } 103 | } 104 | 105 | // Parse command line arguments 106 | const projectId = process.argv[2]; 107 | 108 | if (!projectId) { 109 | console.log("Usage: node delete-relationships.js PROJECT_ID"); 110 | console.log("Example: node delete-relationships.js 1"); 111 | process.exit(1); 112 | } 113 | 114 | // Run the deletion 115 | deleteAllRelationships(projectId); 116 | -------------------------------------------------------------------------------- /migrate-parents.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const { getAllJiraIssues, getSpecificJiraIssues } = require("./jira-client"); 3 | const { 4 | getOpenProjectWorkPackages, 5 | setParentWorkPackage, 6 | listProjects, 7 | JIRA_ID_CUSTOM_FIELD, 8 | } = require("./openproject-client"); 9 | 10 | async function migrateParents(jiraProjectKey, openProjectId, specificIssues) { 11 | console.log("Starting parent relationship migration..."); 12 | 13 | // List available projects 14 | await listProjects(); 15 | 16 | // Get work packages from OpenProject 17 | console.log(`\nFetching work packages for project ${openProjectId}...`); 18 | const workPackages = await getOpenProjectWorkPackages(openProjectId); 19 | 20 | // Create a map of Jira keys to work package IDs 21 | const jiraKeyToWorkPackageId = new Map(); 22 | workPackages.forEach((wp) => { 23 | const jiraKey = wp[`customField${JIRA_ID_CUSTOM_FIELD}`]; 24 | if (jiraKey) { 25 | jiraKeyToWorkPackageId.set(jiraKey, wp.id); 26 | } 27 | }); 28 | 29 | console.log("\nCache Summary:"); 30 | console.log(`- Total work packages: ${workPackages.length}`); 31 | console.log(`- Work packages with Jira ID: ${jiraKeyToWorkPackageId.size}`); 32 | console.log( 33 | `- Work packages without Jira ID: ${ 34 | workPackages.length - jiraKeyToWorkPackageId.size 35 | }` 36 | ); 37 | console.log( 38 | `- Cached ${jiraKeyToWorkPackageId.size} work packages for quick lookup` 39 | ); 40 | console.log("=======================================\n"); 41 | 42 | // Get Jira issues 43 | const jiraIssues = specificIssues 44 | ? await getSpecificJiraIssues(jiraProjectKey, specificIssues.split(",")) 45 | : await getAllJiraIssues(jiraProjectKey); 46 | 47 | console.log(`Found ${jiraIssues.length} Jira issues to process`); 48 | 49 | // Process each issue 50 | let processed = 0; 51 | let completed = 0; 52 | let skipped = 0; 53 | let errors = 0; 54 | 55 | for (const issue of jiraIssues) { 56 | try { 57 | console.log(`\nProcessing ${issue.key}...`); 58 | 59 | // Check for parent field 60 | const parentKey = issue.fields.parent?.key; 61 | if (!parentKey) { 62 | console.log(`No parent found for ${issue.key}`); 63 | skipped++; 64 | continue; 65 | } 66 | 67 | console.log(`Found parent ${parentKey} for ${issue.key}`); 68 | 69 | // Get the work package IDs 70 | const workPackageId = jiraKeyToWorkPackageId.get(issue.key); 71 | const parentWorkPackageId = jiraKeyToWorkPackageId.get(parentKey); 72 | 73 | if (!workPackageId || !parentWorkPackageId) { 74 | console.error( 75 | `Could not find work package IDs for ${issue.key} or its parent ${parentKey}` 76 | ); 77 | errors++; 78 | continue; 79 | } 80 | 81 | try { 82 | await setParentWorkPackage(workPackageId, parentWorkPackageId); 83 | console.log(`Set parent relationship: ${issue.key} -> ${parentKey}`); 84 | completed++; 85 | } catch (error) { 86 | console.error(`Error setting parent for ${issue.key}:`, error.message); 87 | errors++; 88 | } 89 | } catch (error) { 90 | console.error(`Error processing ${issue.key}:`, error.message); 91 | if (error.response?.data) { 92 | console.error( 93 | "Error details:", 94 | JSON.stringify(error.response.data, null, 2) 95 | ); 96 | } 97 | errors++; 98 | } 99 | processed++; 100 | } 101 | 102 | // Print summary 103 | console.log("\nMigration summary:"); 104 | console.log(`Total issues processed: ${processed}`); 105 | console.log(`Completed: ${completed}`); 106 | console.log(`Skipped (no parent): ${skipped}`); 107 | console.log(`Errors: ${errors}`); 108 | } 109 | 110 | // Parse command line arguments 111 | const jiraProjectKey = process.argv[2]; 112 | const openProjectId = process.argv[3]; 113 | const specificIssues = process.argv[4]; 114 | 115 | if (!jiraProjectKey || !openProjectId) { 116 | console.error("Please provide a Jira project key and OpenProject ID"); 117 | console.log( 118 | "Usage: node migrate-parents.js PROJECT_KEY PROJECT_ID [ISSUE_KEYS]" 119 | ); 120 | process.exit(1); 121 | } 122 | 123 | // Run the migration 124 | migrateParents(jiraProjectKey, openProjectId, specificIssues); 125 | -------------------------------------------------------------------------------- /generate-user-mapping.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const axios = require("axios"); 5 | const inquirer = require("inquirer"); 6 | 7 | // Jira API configuration 8 | const jiraConfig = { 9 | baseURL: `https://${process.env.JIRA_HOST}/rest/api/3`, 10 | auth: { 11 | username: process.env.JIRA_EMAIL, 12 | password: process.env.JIRA_API_TOKEN, 13 | }, 14 | }; 15 | 16 | // OpenProject API configuration 17 | const openProjectConfig = { 18 | baseURL: `${process.env.OPENPROJECT_HOST}/api/v3`, 19 | headers: { 20 | Authorization: `Basic ${Buffer.from( 21 | `apikey:${process.env.OPENPROJECT_API_KEY}` 22 | ).toString("base64")}`, 23 | "Content-Type": "application/json", 24 | }, 25 | }; 26 | 27 | const jiraApi = axios.create(jiraConfig); 28 | const openProjectApi = axios.create(openProjectConfig); 29 | 30 | async function getJiraUsers() { 31 | try { 32 | console.log("\nFetching Jira users..."); 33 | const response = await jiraApi.get("/users/search", { 34 | params: { 35 | maxResults: 1000, 36 | }, 37 | }); 38 | return response.data.map((user) => ({ 39 | accountId: user.accountId, 40 | displayName: user.displayName, 41 | emailAddress: user.emailAddress, 42 | active: user.active, 43 | })); 44 | } catch (error) { 45 | console.error("Error fetching Jira users:", error.message); 46 | throw error; 47 | } 48 | } 49 | 50 | async function getOpenProjectUsers() { 51 | try { 52 | console.log("\nFetching OpenProject users..."); 53 | const response = await openProjectApi.get("/users"); 54 | return response.data._embedded.elements.map((user) => ({ 55 | id: user.id, 56 | name: user.name, 57 | email: user.email, 58 | status: user.status, 59 | })); 60 | } catch (error) { 61 | console.error("Error fetching OpenProject users:", error.message); 62 | throw error; 63 | } 64 | } 65 | 66 | async function generateMapping() { 67 | try { 68 | // Fetch users from both systems 69 | const jiraUsers = await getJiraUsers(); 70 | const openProjectUsers = await getOpenProjectUsers(); 71 | 72 | console.log("\nJira Users:"); 73 | jiraUsers.forEach((user) => { 74 | console.log(`- ${user.displayName} (${user.emailAddress || "No email"})`); 75 | }); 76 | 77 | console.log("\nOpenProject Users:"); 78 | openProjectUsers.forEach((user) => { 79 | console.log(`- ${user.name} (${user.email || "No email"})`); 80 | }); 81 | 82 | // #31: Read existing mapping if available 83 | const mappingPath = path.join(__dirname, "user-mapping.js"); 84 | if (fs.existsSync(mappingPath)) { 85 | /** `var` to keep existingMapping in function scope */ 86 | var existingMapping = require(mappingPath); 87 | console.log( 88 | "\nExisting user mapping found, pre-filling answers where possible." 89 | ); 90 | } else { 91 | console.log("\nNo existing user mapping found."); 92 | } 93 | 94 | // Create mapping through interactive prompts 95 | const mapping = {}; 96 | const openProjectChoices = [ 97 | // Skip option first, so that it appears at the top and can be selected easily 98 | // for the many system users that may not have a corresponding user in OpenProject 99 | { name: "Skip this user", value: null }, 100 | ...openProjectUsers.map((user) => ({ 101 | name: `${user.name} (${user.email || "No email"})`, 102 | value: user.id, 103 | })), 104 | ]; 105 | for (const jiraUser of jiraUsers) { 106 | if (!jiraUser.active) continue; 107 | 108 | let choices = openProjectChoices; 109 | // #31: Pre-fill existing mapping if available 110 | if (existingMapping) { 111 | const existingAnswer = openProjectChoices.find( 112 | (choice) => choice.value === existingMapping[jiraUser.accountId] 113 | ); 114 | if (existingAnswer) { 115 | // Add existing answer first so that it appears selected 116 | choices = [existingAnswer, ...openProjectChoices]; 117 | } 118 | } 119 | 120 | const answer = await inquirer.prompt([ 121 | { 122 | type: "list", 123 | name: "openProjectId", 124 | message: `Select OpenProject user for Jira user: ${ 125 | jiraUser.displayName 126 | } (${jiraUser.emailAddress || "No email"})`, 127 | choices, 128 | }, 129 | ]); 130 | 131 | if (answer.openProjectId !== null) { 132 | mapping[jiraUser.accountId] = answer.openProjectId; 133 | } 134 | } 135 | 136 | // Save mapping to file 137 | const mappingContent = `// Generated user mapping - ${new Date().toISOString()} 138 | const userMapping = ${JSON.stringify(mapping, null, 2)}; 139 | 140 | module.exports = userMapping; 141 | `; 142 | 143 | fs.writeFileSync(path.join(__dirname, "user-mapping.js"), mappingContent); 144 | console.log("\nUser mapping has been saved to user-mapping.js"); 145 | 146 | return mapping; 147 | } catch (error) { 148 | console.error("Error generating mapping:", error.message); 149 | throw error; 150 | } 151 | } 152 | 153 | // If running directly (not imported) 154 | if (require.main === module) { 155 | generateMapping().catch(console.error); 156 | } 157 | 158 | module.exports = { generateMapping }; 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenProject Jira Migration Tool 2 | 3 | A tool to migrate issues from Jira to OpenProject, including attachments, comments, relationships, and priorities. 4 | 5 | ## Features 6 | 7 | - Migrates issues with their descriptions, priorities, and statuses 8 | - Preserves issue relationships and hierarchies 9 | - Migrates attachments and comments 10 | - Migrates watchers 11 | - Maps Jira users to OpenProject users 12 | - Tracks original Jira issue IDs 13 | - Handles incremental migrations 14 | 15 | ## Prerequisites 16 | 17 | 1. Node.js installed 18 | 2. Access to both Jira and OpenProject instances 19 | 3. API tokens/keys for both systems 20 | 4. Custom field in OpenProject to store Jira issue IDs 21 | 22 | ## Setup 23 | 24 | 1. Clone this repository 25 | 2. Run `npm install` to install dependencies 26 | 3. Copy `.env.example` to `.env` 27 | 4. Configure your environment variables in `.env` 28 | 29 | ### Environment Variables 30 | 31 | #### Jira Configuration 32 | - `JIRA_HOST`: Your Jira instance hostname (e.g., your-domain.atlassian.net) 33 | - `JIRA_EMAIL`: Your Jira email address 34 | - `JIRA_API_TOKEN`: Your Jira API token (generate at https://id.atlassian.com/manage-profile/security/api-tokens) 35 | 36 | #### OpenProject Configuration 37 | - `OPENPROJECT_HOST`: Your OpenProject instance URL 38 | - `OPENPROJECT_API_KEY`: Your OpenProject API key (generate in Settings > My account > Access token) 39 | 40 | #### Custom Field Configuration 41 | - `JIRA_ID_CUSTOM_FIELD`: The ID of the custom field in OpenProject that stores the Jira issue ID 42 | - This must be a text custom field 43 | - Find the ID in OpenProject: Administration > Custom fields > Work packages 44 | - Default value is 1 if not specified 45 | 46 | ### OpenProject Custom Field Setup 47 | 48 | 1. In OpenProject, go to Administration > Custom fields > Work packages 49 | 2. Create a new text custom field (if not already exists) 50 | 3. Note the ID of the custom field 51 | 4. Set this ID in your `.env` file as `JIRA_ID_CUSTOM_FIELD` 52 | 53 | ## Usage 54 | 55 | Run the migration tool: 56 | 57 | ```bash 58 | node migrate.js 59 | ``` 60 | 61 | Follow the interactive prompts to: 62 | 1. Select source Jira project 63 | 2. Select target OpenProject project 64 | 3. Choose migration type (full or specific issues) 65 | 4. Confirm existing issue handling 66 | 67 | For non-interactive usage or specific issues: 68 | 69 | ```bash 70 | # Migrate specific issues 71 | node migrate.js JIRA_PROJECT_KEY OPENPROJECT_ID ISSUE1,ISSUE2 72 | 73 | # Migrate relationships only 74 | node migrate-relationships.js JIRA_PROJECT_KEY OPENPROJECT_ID 75 | 76 | # Migrate parent-child hierarchies only 77 | node migrate-parents.js JIRA_PROJECT_KEY OPENPROJECT_ID [ISSUE1,ISSUE2] 78 | ``` 79 | 80 | The `migrate-parents.js` script specifically handles parent-child hierarchies from Jira to OpenProject. While `migrate-relationships.js` handles all types of relationships (blocks, relates, etc.), this script focuses only on the hierarchical structure. 81 | 82 | Key features: 83 | - Migrates Jira's parent-child relationships to OpenProject's hierarchical structure 84 | - Can process specific issues or entire project 85 | - Preserves existing work package data 86 | - Shows detailed progress and results 87 | 88 | Use this when: 89 | - You need to fix hierarchy issues 90 | - You want to migrate parent-child relationships separately 91 | - You're troubleshooting hierarchy-specific problems 92 | 93 | Note: Run this script before `migrate-relationships.js` as OpenProject doesn't allow both parent-child hierarchies and "partof"/"includes" relationships between the same work packages. 94 | 95 | ```bash 96 | # Remove duplicate work packages 97 | node remove-duplicates.js OPENPROJECT_ID 98 | 99 | # Delete all relationships 100 | node delete-relationships.js OPENPROJECT_ID 101 | ``` 102 | 103 | This will delete all relationships (including parent-child hierarchies) between work packages in the specified project. 104 | Useful for: 105 | - Testing relationship migration 106 | - Cleaning up before re-running relationship migration 107 | - Removing problematic relationships 108 | 109 | The script preserves all work packages and their data, only removing the relationships between them. 110 | 111 | ## Troubleshooting 112 | 113 | If you encounter issues: 114 | 115 | 1. Check your API tokens and permissions 116 | 2. Verify the custom field ID is correct 117 | 3. Ensure users are properly mapped 118 | 4. Check the console output for detailed error messages 119 | 120 | ## Need Professional Help? 121 | 122 | Don't want to handle the migration yourself? We offer a complete done-for-you service that includes: 123 | 124 | - Managed OpenProject hosting 125 | - Complete Jira migration 126 | - 24/7 technical support 127 | - Secure and reliable infrastructure 128 | 129 | Visit [portfolio.elitecoders.co/openproject](https://portfolio.elitecoders.co/openproject) to learn more about our managed OpenProject migration service. 130 | 131 | ## About 132 | 133 | This project was built by [EliteCoders](https://www.elitecoders.co), a software development company specializing in custom software solutions. If you need help with: 134 | 135 | - Custom software development 136 | - System integration 137 | - Migration tools and services 138 | - Technical consulting 139 | 140 | Please reach out to us at hello@elitecoders.co or visit our website at [www.elitecoders.co](https://www.elitecoders.co). 141 | 142 | ## Contributing 143 | 144 | Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. 145 | 146 | Please make sure to update tests as appropriate. 147 | 148 | ## License 149 | 150 | [MIT](LICENSE) - see the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /migrate.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const inquirer = require("inquirer"); 3 | const { migrateIssues } = require("./index.js"); 4 | const { listProjects } = require("./jira-client"); 5 | const { 6 | listProjects: listOpenProjectProjects, 7 | } = require("./openproject-client"); 8 | 9 | async function promptForMigrationOptions() { 10 | try { 11 | // Get list of Jira projects 12 | console.log("\nFetching Jira projects..."); 13 | const jiraProjects = await listProjects(); 14 | 15 | // Get list of OpenProject projects 16 | console.log("\nFetching OpenProject projects..."); 17 | const openProjectProjects = await listOpenProjectProjects(); 18 | 19 | // Prompt for Jira project 20 | const { jiraProject } = await inquirer.prompt([ 21 | { 22 | type: "list", 23 | name: "jiraProject", 24 | message: "Select the Jira project to migrate:", 25 | choices: jiraProjects.map((project) => ({ 26 | name: `${project.key} - ${project.name}`, 27 | value: project.key, 28 | })), 29 | }, 30 | ]); 31 | 32 | // Prompt for OpenProject project 33 | const { openProjectId } = await inquirer.prompt([ 34 | { 35 | type: "list", 36 | name: "openProjectId", 37 | message: "Select the OpenProject project to migrate to:", 38 | choices: openProjectProjects.map((project) => ({ 39 | name: `${project.name} (ID: ${project.id})`, 40 | value: project.id, 41 | })), 42 | }, 43 | ]); 44 | 45 | // Prompt for migration type 46 | const { migrationType } = await inquirer.prompt([ 47 | { 48 | type: "list", 49 | name: "migrationType", 50 | message: "What type of migration would you like to perform?", 51 | choices: [ 52 | { name: "Full migration", value: "full" }, 53 | { name: "Test migration (no changes in production)", value: "test" }, 54 | { name: "Specific issues", value: "specific" }, 55 | ], 56 | }, 57 | ]); 58 | 59 | let isProd = false; 60 | let skipUpdates = false; 61 | let specificIssues = null; 62 | 63 | if (migrationType === "full") { 64 | // Prompt for update mode 65 | const { updateMode } = await inquirer.prompt([ 66 | { 67 | type: "list", 68 | name: "updateMode", 69 | message: "How would you like to handle existing issues?", 70 | choices: [ 71 | { name: "Add new issues only (skip existing)", value: "skip" }, 72 | { 73 | name: "Add new issues and update existing ones", 74 | value: "update", 75 | }, 76 | ], 77 | }, 78 | ]); 79 | 80 | isProd = true; 81 | skipUpdates = updateMode === "skip"; 82 | } else if (migrationType === "specific") { 83 | // Prompt for specific issues 84 | const { issues } = await inquirer.prompt([ 85 | { 86 | type: "input", 87 | name: "issues", 88 | message: 89 | "Enter the Jira issue keys (comma-separated, e.g., PROJ-123,PROJ-124):", 90 | validate: (input) => { 91 | if (!input.trim()) return "Please enter at least one issue key"; 92 | const pattern = /^[A-Z]+-\d+(,[A-Z]+-\d+)*$/; 93 | if (!pattern.test(input)) 94 | return "Please enter valid issue keys (e.g., PROJ-123,PROJ-124)"; 95 | return true; 96 | }, 97 | }, 98 | ]); 99 | specificIssues = issues.split(","); 100 | isProd = true; 101 | } 102 | 103 | // Prompt for responsible mapping 104 | const { mapResponsible } = await inquirer.prompt([ 105 | { 106 | type: "confirm", 107 | name: "mapResponsible", 108 | message: "Map Jira creator to OpenProject accountable?", 109 | default: true, 110 | }, 111 | ]); 112 | 113 | // Confirm migration settings 114 | console.log("\nMigration Settings:"); 115 | console.log(`- Jira Project: ${jiraProject}`); 116 | console.log(`- OpenProject ID: ${openProjectId}`); 117 | console.log(`- Migration Type: ${migrationType}`); 118 | if (migrationType === "full") { 119 | console.log( 120 | `- Update Mode: ${skipUpdates ? "Skip existing" : "Update existing"}` 121 | ); 122 | } else if (migrationType === "specific") { 123 | console.log(`- Specific Issues: ${specificIssues.join(", ")}`); 124 | } 125 | console.log(`- Map Responsible: ${mapResponsible ? "Yes" : "No"}`); 126 | 127 | const { confirm } = await inquirer.prompt([ 128 | { 129 | type: "confirm", 130 | name: "confirm", 131 | message: "Would you like to proceed with these settings?", 132 | default: true, 133 | }, 134 | ]); 135 | 136 | if (!confirm) { 137 | console.log("Migration cancelled."); 138 | process.exit(0); 139 | } 140 | 141 | // Start migration 142 | console.log("\nStarting migration..."); 143 | await migrateIssues( 144 | jiraProject, 145 | openProjectId, 146 | isProd, 147 | specificIssues, 148 | skipUpdates, 149 | mapResponsible 150 | ); 151 | } catch (error) { 152 | console.error("Error during migration setup:", error.message); 153 | process.exit(1); 154 | } 155 | } 156 | 157 | // Parse command line arguments or use interactive mode 158 | const args = process.argv.slice(2); 159 | if (args.length > 0) { 160 | // Use command line arguments 161 | const isProd = args.includes("--prod"); 162 | const skipUpdates = args.includes("--skip-updates"); 163 | const specificIndex = args.indexOf("--specific"); 164 | const specificIssues = 165 | specificIndex !== -1 ? args[specificIndex + 1].split(",") : null; 166 | const mapResponsible = !args.includes("--no-responsible"); // Default to true unless --no-responsible is specified 167 | const jiraProject = args[0]; 168 | const openProjectId = parseInt(args[1]); 169 | 170 | if (!jiraProject || !openProjectId) { 171 | console.log( 172 | "Usage: node migrate.js JIRA_PROJECT_KEY OPENPROJECT_ID [--prod] [--skip-updates] [--specific ISSUE1,ISSUE2] [--no-responsible]" 173 | ); 174 | process.exit(1); 175 | } 176 | 177 | // Start migration after a delay to allow initialization to complete 178 | setTimeout(() => { 179 | migrateIssues( 180 | jiraProject, 181 | openProjectId, 182 | isProd, 183 | specificIssues, 184 | skipUpdates, 185 | mapResponsible 186 | ); 187 | }, 2000); 188 | } else { 189 | // Use interactive mode 190 | promptForMigrationOptions(); 191 | } 192 | -------------------------------------------------------------------------------- /remove-duplicates.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const axios = require("axios"); 3 | const { JIRA_ID_CUSTOM_FIELD } = require("./openproject-client"); 4 | 5 | // OpenProject API configuration 6 | const openProjectConfig = { 7 | baseURL: `${process.env.OPENPROJECT_HOST}/api/v3`, 8 | headers: { 9 | Authorization: `Basic ${Buffer.from( 10 | `apikey:${process.env.OPENPROJECT_API_KEY}` 11 | ).toString("base64")}`, 12 | "Content-Type": "application/json", 13 | }, 14 | }; 15 | 16 | const openProjectApi = axios.create(openProjectConfig); 17 | 18 | async function getAllWorkPackages(projectId) { 19 | try { 20 | console.log("\n=== Fetching All Work Packages ==="); 21 | console.log("Fetching work packages from OpenProject..."); 22 | 23 | let allWorkPackages = []; 24 | let page = 1; 25 | const pageSize = 100; 26 | let total = null; 27 | 28 | while (true) { 29 | console.log(`Fetching page ${page}...`); 30 | 31 | const response = await openProjectApi.get("/work_packages", { 32 | params: { 33 | filters: JSON.stringify([ 34 | { 35 | project: { 36 | operator: "=", 37 | values: [projectId.toString()], 38 | }, 39 | }, 40 | ]), 41 | pageSize: pageSize, 42 | offset: page, 43 | }, 44 | }); 45 | 46 | if (total === null) { 47 | total = parseInt(response.data.total); 48 | console.log(`Total work packages to fetch: ${total}`); 49 | } 50 | 51 | const workPackages = response.data._embedded.elements; 52 | if (!workPackages || workPackages.length === 0) { 53 | break; 54 | } 55 | 56 | allWorkPackages = allWorkPackages.concat(workPackages); 57 | console.log( 58 | `Retrieved ${ 59 | allWorkPackages.length 60 | } of ${total} work packages (${Math.round( 61 | (allWorkPackages.length / total) * 100 62 | )}%)` 63 | ); 64 | 65 | if (allWorkPackages.length >= total) { 66 | break; 67 | } 68 | 69 | page++; 70 | 71 | // Add a small delay to prevent rate limiting 72 | await new Promise((resolve) => setTimeout(resolve, 100)); 73 | } 74 | 75 | return allWorkPackages; 76 | } catch (error) { 77 | console.error("Error fetching work packages:", error.message); 78 | if (error.response?.data) { 79 | console.error( 80 | "Error details:", 81 | JSON.stringify(error.response.data, null, 2) 82 | ); 83 | } 84 | throw error; 85 | } 86 | } 87 | 88 | async function findDuplicates(workPackages) { 89 | console.log("\n=== Analyzing Work Packages ==="); 90 | 91 | // Group work packages by Jira ID 92 | const groupedByJiraId = new Map(); 93 | let workPackagesWithJiraId = 0; 94 | let workPackagesWithoutJiraId = 0; 95 | 96 | workPackages.forEach((wp) => { 97 | const jiraId = wp[`customField${JIRA_ID_CUSTOM_FIELD}`]; 98 | if (jiraId) { 99 | workPackagesWithJiraId++; 100 | if (!groupedByJiraId.has(jiraId)) { 101 | groupedByJiraId.set(jiraId, []); 102 | } 103 | groupedByJiraId.get(jiraId).push(wp); 104 | } else { 105 | workPackagesWithoutJiraId++; 106 | } 107 | }); 108 | 109 | console.log("\nAnalysis Summary:"); 110 | console.log(`- Total work packages: ${workPackages.length}`); 111 | console.log(`- Work packages with Jira ID: ${workPackagesWithJiraId}`); 112 | console.log(`- Work packages without Jira ID: ${workPackagesWithoutJiraId}`); 113 | 114 | // Find duplicates 115 | const duplicates = new Map(); 116 | for (const [jiraId, wps] of groupedByJiraId.entries()) { 117 | if (wps.length > 1) { 118 | // Sort by creation date (newest first) 119 | wps.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); 120 | duplicates.set(jiraId, wps); 121 | } 122 | } 123 | 124 | console.log( 125 | `\nFound ${duplicates.size} Jira IDs with duplicate work packages` 126 | ); 127 | 128 | return duplicates; 129 | } 130 | 131 | async function deleteWorkPackage(workPackageId) { 132 | try { 133 | await openProjectApi.delete(`/work_packages/${workPackageId}`); 134 | return true; 135 | } catch (error) { 136 | console.error( 137 | `Error deleting work package ${workPackageId}:`, 138 | error.message 139 | ); 140 | if (error.response?.data) { 141 | console.error( 142 | "Error details:", 143 | JSON.stringify(error.response.data, null, 2) 144 | ); 145 | } 146 | return false; 147 | } 148 | } 149 | 150 | async function removeDuplicates(projectId) { 151 | try { 152 | // Get all work packages 153 | const workPackages = await getAllWorkPackages(projectId); 154 | 155 | // Find duplicates 156 | const duplicates = await findDuplicates(workPackages); 157 | 158 | if (duplicates.size === 0) { 159 | console.log("\nNo duplicates found. Nothing to do."); 160 | return; 161 | } 162 | 163 | console.log("\n=== Duplicate Work Packages ==="); 164 | for (const [jiraId, wps] of duplicates.entries()) { 165 | console.log(`\nJira ID: ${jiraId}`); 166 | console.log(`Found ${wps.length} duplicates:`); 167 | wps.forEach((wp, index) => { 168 | console.log( 169 | `${index === 0 ? " [KEEP]" : " [DELETE]"} ID: ${wp.id}, Created: ${ 170 | wp.createdAt 171 | }, Subject: ${wp.subject}` 172 | ); 173 | }); 174 | } 175 | 176 | // Confirm before deletion 177 | console.log("\n=== WARNING ==="); 178 | console.log( 179 | "This will delete duplicate work packages, keeping only the newest one for each Jira ID." 180 | ); 181 | console.log("Please review the list above carefully."); 182 | console.log("To proceed, call removeDuplicates with the --confirm flag."); 183 | 184 | // Check if --confirm flag is present 185 | if (process.argv.includes("--confirm")) { 186 | console.log("\n=== Removing Duplicates ==="); 187 | let deletedCount = 0; 188 | let errorCount = 0; 189 | 190 | for (const wps of duplicates.values()) { 191 | // Skip the first one (newest) 192 | for (let i = 1; i < wps.length; i++) { 193 | console.log(`Deleting work package ${wps[i].id}...`); 194 | const success = await deleteWorkPackage(wps[i].id); 195 | if (success) { 196 | deletedCount++; 197 | } else { 198 | errorCount++; 199 | } 200 | } 201 | } 202 | 203 | console.log("\n=== Cleanup Complete ==="); 204 | console.log(`Successfully deleted: ${deletedCount}`); 205 | console.log(`Failed to delete: ${errorCount}`); 206 | } 207 | } catch (error) { 208 | console.error("Error removing duplicates:", error.message); 209 | } 210 | } 211 | 212 | // Parse command line arguments 213 | const projectId = process.argv[2]; 214 | 215 | if (!projectId) { 216 | console.error("Please provide a project ID"); 217 | console.log("Usage: node remove-duplicates.js PROJECT_ID [--confirm]"); 218 | process.exit(1); 219 | } 220 | 221 | // Run the script 222 | removeDuplicates(projectId); 223 | -------------------------------------------------------------------------------- /jira-client.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const axios = require("axios"); 3 | const path = require("path"); 4 | const fs = require("fs"); 5 | 6 | // Jira API configuration 7 | const jiraConfig = { 8 | baseURL: `https://${process.env.JIRA_HOST}/rest/api/3`, 9 | auth: { 10 | username: process.env.JIRA_EMAIL, 11 | password: process.env.JIRA_API_TOKEN, 12 | }, 13 | }; 14 | 15 | const jiraApi = axios.create(jiraConfig); 16 | 17 | // Create a download client without default content-type 18 | const downloadClient = axios.create({ 19 | ...jiraConfig, 20 | responseType: "arraybuffer", 21 | }); 22 | 23 | const DEFAULT_FIELDS = [ 24 | "summary", 25 | "description", 26 | "status", 27 | "priority", 28 | "issuetype", 29 | "attachment", 30 | "comment", 31 | "issuelinks", 32 | "assignee", 33 | "creator", 34 | "created", 35 | "customfield_10014", // Epic Link field 36 | "parent", 37 | "watches", 38 | ].join(","); 39 | 40 | async function getAllJiraIssues(projectKey, fields = DEFAULT_FIELDS) { 41 | try { 42 | let allIssues = []; 43 | const maxResults = 100; 44 | let nextPageToken = undefined; 45 | 46 | // Validate project key 47 | if (!projectKey) { 48 | throw new Error("Project key is required"); 49 | } 50 | 51 | // Validate project key format 52 | if (!/^[A-Z][A-Z0-9_]+$/.test(projectKey)) { 53 | throw new Error( 54 | "Invalid project key format. Project keys should be uppercase and may contain numbers." 55 | ); 56 | } 57 | 58 | let page = 1; 59 | while (true) { 60 | console.log(`Fetching issues page ${page}...`); 61 | try { 62 | const body = { 63 | jql: `project = "${projectKey}" ORDER BY created ASC`, 64 | maxResults, 65 | fields: fields.split ? fields.split(",") : fields, 66 | expand: "renderedFields", 67 | }; 68 | if (nextPageToken) body.nextPageToken = nextPageToken; 69 | const response = await jiraApi.post("/search/jql", body); 70 | const { issues, nextPageToken: newToken } = response.data; 71 | 72 | if (!issues || issues.length === 0) { 73 | if (allIssues.length === 0) { 74 | console.warn( 75 | `No issues found in project ${projectKey}. Please check:` 76 | ); 77 | console.warn("1. The project key is correct"); 78 | console.warn("2. The project contains issues"); 79 | console.warn("3. You have permission to view issues"); 80 | } 81 | break; 82 | } 83 | 84 | allIssues = allIssues.concat(issues); 85 | if (!newToken) { 86 | console.log(`Retrieved all ${allIssues.length} issues`); 87 | break; 88 | } 89 | nextPageToken = newToken; 90 | page++; 91 | } catch (error) { 92 | if (error.response?.status === 400) { 93 | console.error(`\nError fetching issues for project ${projectKey}:`); 94 | console.error("1. Verify the project key exists"); 95 | console.error("2. Check you have access to the project"); 96 | console.error("3. Ensure the project key is in the correct format"); 97 | if (error.response.data) { 98 | console.error("\nJira API Error Details:", error.response.data); 99 | } 100 | } 101 | throw error; 102 | } 103 | } 104 | 105 | return allIssues; 106 | } catch (error) { 107 | console.error("Error fetching Jira issues:"); 108 | if (error.response) { 109 | console.error(`Status code: ${error.response.status}`); 110 | console.error("Response data:", error.response.data); 111 | } 112 | throw error; 113 | } 114 | } 115 | 116 | async function getSpecificJiraIssues( 117 | projectKey, 118 | issueKeys, 119 | fields = DEFAULT_FIELDS 120 | ) { 121 | try { 122 | console.log(`Fetching specific issues: ${issueKeys.join(", ")}...`); 123 | const body = { 124 | jql: `key in ("${issueKeys.join('","')}")`, 125 | maxResults: issueKeys.length, 126 | fields: fields.split ? fields.split(",") : fields, 127 | expand: "renderedFields", 128 | }; 129 | const response = await jiraApi.post("/search/jql", body); 130 | return response.data.issues; 131 | } catch (error) { 132 | console.error("Error fetching specific Jira issues:", error.message); 133 | throw error; 134 | } 135 | } 136 | 137 | async function getJiraUserEmail(accountId) { 138 | try { 139 | console.log(`Fetching email for Jira user with accountId: ${accountId}`); 140 | const response = await jiraApi.get(`/user/properties/email`, { 141 | params: { 142 | accountId: accountId, 143 | }, 144 | }); 145 | console.log("Jira API response:", response.data); 146 | return response.data.value; 147 | } catch (error) { 148 | console.error("Error fetching Jira user email:", error.message); 149 | return null; 150 | } 151 | } 152 | 153 | async function downloadAttachment(url, filePath) { 154 | try { 155 | const response = await downloadClient.get(url); 156 | const tempDir = path.dirname(filePath); 157 | if (!fs.existsSync(tempDir)) { 158 | fs.mkdirSync(tempDir, { recursive: true }); 159 | } 160 | fs.writeFileSync(filePath, response.data); 161 | return filePath; 162 | } catch (error) { 163 | console.error(`Error downloading attachment: ${error.message}`); 164 | return null; 165 | } 166 | } 167 | 168 | async function listProjects() { 169 | try { 170 | const response = await jiraApi.get("/project"); 171 | if (!response.data || response.data.length === 0) { 172 | console.error( 173 | "No projects found in Jira. Please check your permissions." 174 | ); 175 | console.error( 176 | "Make sure your Jira API token has access to view projects." 177 | ); 178 | throw new Error("No projects found"); 179 | } 180 | return response.data; 181 | } catch (error) { 182 | console.error("Error fetching Jira projects:"); 183 | if (error.response) { 184 | // The request was made and the server responded with a status code 185 | // that falls out of the range of 2xx 186 | console.error(`Status code: ${error.response.status}`); 187 | console.error("Response data:", error.response.data); 188 | console.error("Response headers:", error.response.headers); 189 | 190 | if (error.response.status === 401) { 191 | console.error("\nAuthentication failed. Please check:"); 192 | console.error("1. Your JIRA_EMAIL is correct"); 193 | console.error("2. Your JIRA_API_TOKEN is valid and not expired"); 194 | console.error("3. Your JIRA_HOST is correct"); 195 | } else if (error.response.status === 403) { 196 | console.error("\nPermission denied. Please check:"); 197 | console.error("1. Your API token has sufficient permissions"); 198 | console.error("2. You have access to the Jira instance"); 199 | } else if (error.response.status === 400) { 200 | console.error("\nInvalid request. Please check:"); 201 | console.error( 202 | "1. Your JIRA_HOST is in the correct format (e.g., your-domain.atlassian.net)" 203 | ); 204 | console.error("2. The project key is valid"); 205 | } 206 | } else if (error.request) { 207 | // The request was made but no response was received 208 | console.error("No response received from Jira. Please check:"); 209 | console.error("1. Your internet connection"); 210 | console.error("2. The Jira host is accessible"); 211 | console.error("3. JIRA_HOST is correct in your .env file"); 212 | } else { 213 | // Something happened in setting up the request that triggered an Error 214 | console.error("Error setting up the request:", error.message); 215 | } 216 | throw error; 217 | } 218 | } 219 | 220 | async function getIssueWatchers(issueKey) { 221 | try { 222 | console.log(`Fetching watchers for Jira issue ${issueKey}...`); 223 | const response = await jiraApi.get(`/issue/${issueKey}/watchers`); 224 | console.log( 225 | `Found ${response.data.watchers?.length || 0} watchers for ${issueKey}` 226 | ); 227 | if (response.data.watchers?.length > 0) { 228 | console.log( 229 | "Watchers:", 230 | response.data.watchers.map((w) => w.displayName).join(", ") 231 | ); 232 | } 233 | return response.data; 234 | } catch (error) { 235 | console.error( 236 | `Error getting watchers for issue ${issueKey}:`, 237 | error.message 238 | ); 239 | if (error.response?.data) { 240 | console.error( 241 | "Error details:", 242 | JSON.stringify(error.response.data, null, 2) 243 | ); 244 | } 245 | throw error; 246 | } 247 | } 248 | 249 | module.exports = { 250 | getAllJiraIssues, 251 | getSpecificJiraIssues, 252 | getJiraUserEmail, 253 | downloadAttachment, 254 | listProjects, 255 | getIssueWatchers, 256 | DEFAULT_FIELDS, 257 | }; 258 | -------------------------------------------------------------------------------- /create-relationships.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const axios = require("axios"); 3 | 4 | // OpenProject API configuration 5 | const openProjectConfig = { 6 | baseURL: `${process.env.OPENPROJECT_HOST}/api/v3`, 7 | headers: { 8 | Authorization: `Basic ${Buffer.from( 9 | `apikey:${process.env.OPENPROJECT_API_KEY}` 10 | ).toString("base64")}`, 11 | "Content-Type": "application/json", 12 | }, 13 | }; 14 | 15 | const openProjectApi = axios.create(openProjectConfig); 16 | 17 | // Store issue key to work package ID mapping 18 | const issueToWorkPackageMap = new Map(); 19 | 20 | // Track missing relationships to retry later 21 | const missingRelationships = new Set(); 22 | 23 | /** 24 | * Check if a parent relationship already exists between two work packages 25 | * (whatever the direction is). 26 | * Unfortunately, OpenProject does not expose parent relationships via the /relations endpoint, 27 | * so we need to fetch the work package details to check for parent links. 28 | * https://www.openproject.org/docs/api/endpoints/work-packages/#view-work-package 29 | * 30 | * @param {string} fromId of Work Package to check relation for 31 | * @param {string} toId of Work Package to check relation for 32 | * @returns 33 | */ 34 | async function checkParentRelationship(fromId, toId) { 35 | try { 36 | // We do not care about direction here, just if a parent-child relationship exists, 37 | // so we pick one Work Package to fetch and check if the other one is its parent 38 | // or one of its children. 39 | const response = await openProjectApi.get(`/work_packages/${fromId}`); 40 | const parentLink = response.data._links.parent; 41 | const childrenLinks = response.data._links.children; 42 | const toIdStr = toId.toString(); 43 | 44 | if ( 45 | // Check if fromId has toId as parent... 46 | getHrefId(parentLink?.href) === toIdStr || 47 | // ...or if fromId has toId as child 48 | childrenLinks?.find((child) => getHrefId(child?.href) === toIdStr) 49 | ) { 50 | console.log( 51 | `Found existing parent relationship between ${fromId} and ${toId}` 52 | ); 53 | return true; 54 | } 55 | } catch (error) { 56 | console.error(`Error checking parent relationship: ${error.message}`); 57 | } 58 | return false; 59 | } 60 | 61 | /** 62 | * Extracts the ID from a given href by splitting on "/" and returning the last segment. 63 | * @example 64 | * getHrefId("/api/v3/work_packages/123") // returns "123" 65 | * @param {string | undefined} href - The href string to extract the ID from. 66 | * @returns {string | undefined} The extracted ID. 67 | */ 68 | function getHrefId(href) { 69 | return href?.split("/").pop(); 70 | } 71 | 72 | async function checkExistingRelationship(fromId, toId, type) { 73 | try { 74 | // #33: OpenProject does not allow multiple relationships of any type 75 | // between the same two work packages, regardless of direction, 76 | // so detect them before attempting creation. 77 | // https://github.com/opf/openproject/blob/v16.6.3/app/models/work_packages/scopes/relatable.rb#L34-L201 78 | console.log( 79 | `\nChecking for existing relationship between ${fromId} and ${toId}` 80 | ); 81 | // #33: parent-child prevents any other relationships, whatever their type and direction are. 82 | if (await checkParentRelationship(fromId, toId)) { 83 | console.log( 84 | `Found existing parent relationship, skipping ${type} creation` 85 | ); 86 | return true; 87 | } 88 | 89 | // #33: strangely, "or" operator in filters does not seem to work as expected, 90 | // but we can simply list both work package IDs in both "to" and "from" filters 91 | // (this could also match relations from A to A or from B to B, but those are not possible in our case) 92 | const filter = { 93 | operator: "=", 94 | values: [fromId.toString(), toId.toString()], 95 | }; 96 | const filters = [ 97 | // #36: Check both ends of the relation as 2 separate filters 98 | // (OpenProject combines them with AND logic), 99 | // OpenProject expects objects with a single property 100 | // https://github.com/opf/openproject/blob/v16.6.3/app/services/api/v3/parse_query_params_service.rb#L138 101 | { 102 | from: filter, 103 | }, 104 | { 105 | to: filter, 106 | }, 107 | ]; 108 | 109 | // Use the relations endpoint with filters 110 | const response = await openProjectApi.get("/relations", { 111 | params: { 112 | filters: JSON.stringify(filters), 113 | }, 114 | }); 115 | 116 | // Log the API response for debugging 117 | console.log("API Response:", JSON.stringify(response.data, null, 2)); 118 | 119 | // If we find any relations matching our criteria, a relationship exists 120 | const exists = response.data.total > 0; 121 | console.log( 122 | `Relationship exists: ${exists} (found ${response.data.total} matches)` 123 | ); 124 | 125 | // Add detailed logging about any found relationships 126 | if (exists && response.data._embedded.elements.length > 0) { 127 | const relation = response.data._embedded.elements[0]; 128 | console.log("\nFound existing relationship details:"); 129 | console.log(`- Relation ID: ${relation.id}`); 130 | console.log(`- Type: ${relation.type}`); 131 | console.log( 132 | `- From: ${relation._links.from.title} (ID: ${relation._links.from.href 133 | .split("/") 134 | .pop()})` 135 | ); 136 | console.log( 137 | `- To: ${relation._links.to.title} (ID: ${relation._links.to.href 138 | .split("/") 139 | .pop()})` 140 | ); 141 | console.log( 142 | `- Direction: ${ 143 | relation._links.from.href.split("/").pop() === fromId 144 | ? "forward" 145 | : "reverse" 146 | }` 147 | ); 148 | } 149 | 150 | return exists; 151 | } catch (error) { 152 | console.error(`Error checking existing relationship: ${error.message}`); 153 | if (error.response?.data) { 154 | console.error( 155 | "Error details:", 156 | JSON.stringify(error.response.data, null, 2) 157 | ); 158 | } 159 | return false; 160 | } 161 | } 162 | 163 | async function createRelationship(fromId, toId, type) { 164 | try { 165 | console.log( 166 | `\nAttempting to create relationship: ${fromId} ${type} ${toId}` 167 | ); 168 | 169 | // Check if relationship already exists 170 | const exists = await checkExistingRelationship(fromId, toId, type); 171 | if (exists) { 172 | console.log( 173 | `Relationship already exists: ${type} from ${fromId} to ${toId}` 174 | ); 175 | return; 176 | } 177 | 178 | // Create new relationship 179 | const payload = { 180 | type: type, 181 | description: "Created by Jira migration", 182 | lag: 0, 183 | _links: { 184 | to: { 185 | href: `/api/v3/work_packages/${toId}`, 186 | }, 187 | }, 188 | }; 189 | 190 | console.log( 191 | "Creating relationship with payload:", 192 | JSON.stringify(payload, null, 2) 193 | ); 194 | 195 | // The correct endpoint is /api/v3/work_packages/{id}/relations 196 | const response = await openProjectApi.post( 197 | `/work_packages/${fromId}/relations`, 198 | payload 199 | ); 200 | console.log("Creation response:", JSON.stringify(response.data, null, 2)); 201 | console.log(`Created ${type} relationship: ${fromId} -> ${toId}`); 202 | } catch (error) { 203 | console.error( 204 | `Error creating relationship: ${fromId} -> ${toId} ${type} ${error.message}` 205 | ); 206 | if (error.response?.data) { 207 | console.error( 208 | "Error details:", 209 | JSON.stringify(error.response.data, null, 2) 210 | ); 211 | } 212 | } 213 | } 214 | 215 | async function handleRelationships(issue) { 216 | if (!issue.fields.issuelinks && !issue.fields.customfield_10014) return; 217 | 218 | const fromWorkPackageId = issueToWorkPackageMap.get(issue.key); 219 | if (!fromWorkPackageId) return; 220 | 221 | // Handle epic link first 222 | if (issue.fields.customfield_10014) { 223 | const epicKey = issue.fields.customfield_10014; 224 | const epicWorkPackageId = issueToWorkPackageMap.get(epicKey); 225 | if (epicWorkPackageId) { 226 | await createRelationship(fromWorkPackageId, epicWorkPackageId, "partof"); 227 | } else { 228 | console.log( 229 | `Epic ${epicKey} not found in current migration batch, will retry later` 230 | ); 231 | missingRelationships.add( 232 | JSON.stringify({ 233 | fromKey: issue.key, 234 | toKey: epicKey, 235 | type: "partof", 236 | }) 237 | ); 238 | } 239 | } 240 | 241 | // Handle regular issue links 242 | if (!issue.fields.issuelinks || issue.fields.issuelinks.length === 0) return; 243 | 244 | for (const link of issue.fields.issuelinks) { 245 | let relatedIssueKey; 246 | let relationType; 247 | let shouldSkip = false; 248 | 249 | if (link.outwardIssue) { 250 | relatedIssueKey = link.outwardIssue.key; 251 | switch (link.type.outward) { 252 | case "blocks": 253 | relationType = "blocks"; 254 | break; 255 | case "relates to": 256 | relationType = "relates"; 257 | break; 258 | case "is parent of": 259 | relationType = "includes"; 260 | break; 261 | case "duplicates": 262 | // For duplicates, only create the relationship if this is the newer issue 263 | relationType = "duplicates"; 264 | // Skip if we've already processed this pair in the other direction 265 | shouldSkip = issue.fields.created > link.outwardIssue.fields?.created; 266 | break; 267 | default: 268 | relationType = "relates"; 269 | } 270 | } else if (link.inwardIssue) { 271 | relatedIssueKey = link.inwardIssue.key; 272 | switch (link.type.inward) { 273 | case "is blocked by": 274 | relationType = "blocked"; 275 | break; 276 | case "relates to": 277 | relationType = "relates"; 278 | break; 279 | case "is child of": 280 | relationType = "partof"; 281 | break; 282 | case "is duplicated by": 283 | // For duplicates, only create the relationship if this is the newer issue 284 | relationType = "duplicated"; 285 | // Skip if we've already processed this pair in the other direction 286 | shouldSkip = issue.fields.created < link.inwardIssue.fields?.created; 287 | break; 288 | default: 289 | relationType = "relates"; 290 | } 291 | } 292 | 293 | if (shouldSkip) { 294 | console.log( 295 | `Skipping duplicate relationship for ${issue.key} to avoid circular dependency` 296 | ); 297 | continue; 298 | } 299 | 300 | const toWorkPackageId = issueToWorkPackageMap.get(relatedIssueKey); 301 | if (toWorkPackageId) { 302 | try { 303 | await createRelationship( 304 | fromWorkPackageId, 305 | toWorkPackageId, 306 | relationType 307 | ); 308 | } catch (error) { 309 | console.error( 310 | `Failed to create relationship: ${issue.key} ${relationType} ${relatedIssueKey}` 311 | ); 312 | // Store failed relationship to retry 313 | missingRelationships.add( 314 | JSON.stringify({ 315 | fromKey: issue.key, 316 | toKey: relatedIssueKey, 317 | type: relationType, 318 | }) 319 | ); 320 | } 321 | } else { 322 | console.log( 323 | `Skipping relationship: Target issue ${relatedIssueKey} not found in current migration batch` 324 | ); 325 | // Store missing relationship to retry 326 | missingRelationships.add( 327 | JSON.stringify({ 328 | fromKey: issue.key, 329 | toKey: relatedIssueKey, 330 | type: relationType, 331 | }) 332 | ); 333 | } 334 | } 335 | } 336 | 337 | async function retryMissingRelationships() { 338 | if (missingRelationships.size === 0) return; 339 | 340 | console.log( 341 | `\nRetrying ${missingRelationships.size} missing relationships...` 342 | ); 343 | const retryRelationships = Array.from(missingRelationships).map((r) => 344 | JSON.parse(r) 345 | ); 346 | missingRelationships.clear(); 347 | 348 | for (const rel of retryRelationships) { 349 | const fromWorkPackageId = issueToWorkPackageMap.get(rel.fromKey); 350 | const toWorkPackageId = issueToWorkPackageMap.get(rel.toKey); 351 | 352 | if (fromWorkPackageId && toWorkPackageId) { 353 | try { 354 | await createRelationship(fromWorkPackageId, toWorkPackageId, rel.type); 355 | console.log( 356 | `Created relationship: ${rel.fromKey} ${rel.type} ${rel.toKey}` 357 | ); 358 | } catch (error) { 359 | console.error( 360 | `Failed to create relationship: ${rel.fromKey} ${rel.type} ${rel.toKey}` 361 | ); 362 | } 363 | } else { 364 | console.log( 365 | `Still missing work package for relationship: ${rel.fromKey} ${rel.type} ${rel.toKey}` 366 | ); 367 | } 368 | } 369 | } 370 | 371 | async function createRelationships(issues, issueKeyToWorkPackageIdMap) { 372 | try { 373 | console.log("\n=== Creating Relationships ==="); 374 | 375 | // Update the mapping with provided data 376 | for (const [key, id] of Object.entries(issueKeyToWorkPackageIdMap)) { 377 | issueToWorkPackageMap.set(key, id); 378 | } 379 | 380 | // First pass: Create all relationships 381 | for (const issue of issues) { 382 | await handleRelationships(issue); 383 | } 384 | 385 | // Final pass: Retry any missing relationships 386 | await retryMissingRelationships(); 387 | 388 | console.log("\n=== Relationship Creation Complete ==="); 389 | } catch (error) { 390 | console.error("\nRelationship creation failed:", error.message); 391 | } 392 | } 393 | 394 | module.exports = { createRelationships }; 395 | -------------------------------------------------------------------------------- /openproject-client.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const axios = require("axios"); 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const FormData = require("form-data"); 6 | 7 | // OpenProject API configuration 8 | const openProjectConfig = { 9 | baseURL: `${process.env.OPENPROJECT_HOST}/api/v3`, 10 | headers: { 11 | Authorization: `Basic ${Buffer.from( 12 | `apikey:${process.env.OPENPROJECT_API_KEY}` 13 | ).toString("base64")}`, 14 | "Content-Type": "application/json", 15 | }, 16 | }; 17 | 18 | const openProjectApi = axios.create(openProjectConfig); 19 | 20 | // Get the custom field ID from environment variable or use default value 21 | const JIRA_ID_CUSTOM_FIELD = process.env.JIRA_ID_CUSTOM_FIELD || 1; 22 | 23 | // Store work package types and statuses 24 | let workPackageTypes = null; 25 | let workPackageStatuses = null; 26 | let openProjectUsers = null; 27 | let workPackagePriorities = null; 28 | 29 | // Map Jira issue types to OpenProject types 30 | const typeMapping = { 31 | Task: "Task", 32 | Story: "User story", 33 | Bug: "Bug", 34 | Epic: "Epic", 35 | Feature: "Feature", 36 | Milestone: "Milestone", 37 | }; 38 | 39 | // Map Jira statuses to OpenProject statuses 40 | const statusMapping = { 41 | "To Do": "New", 42 | "In Progress": "In progress", 43 | Done: "Closed", 44 | Closed: "Closed", 45 | Resolved: "Closed", 46 | }; 47 | 48 | // Map Jira priorities to OpenProject priorities 49 | const priorityMapping = { 50 | Highest: "Immediate", 51 | High: "High", 52 | Medium: "Normal", 53 | Low: "Low", 54 | Lowest: "Low", 55 | }; 56 | 57 | /** 58 | * Fetches and caches OpenProject work packages, optionally filtered by project ID(s). 59 | * This function retrieves work packages from the OpenProject API in paginated requests, 60 | * maps them by their associated Jira ID (from a custom field), and returns a Map for quick lookup. 61 | * It logs progress, including total counts and cache summaries. 62 | * 63 | * @async 64 | * @param {string|number} projectId - The project ID(s) to filter by. Use "\*" or "all" for all projects, 65 | * a single ID, or comma-separated IDs. If falsy or "\*" or "all", no project filter is applied. 66 | * @returns {Promise>} A Map where keys are Jira IDs (strings) and values are work package objects. 67 | * Only work packages with a Jira ID are included in the Map. 68 | * @throws {Error} If the API request fails, the error is logged and re-thrown. 69 | */ 70 | async function getOpenProjectWorkPackages(projectId) { 71 | console.log("\n=== Caching OpenProject Work Packages ==="); 72 | console.log("Fetching work packages from OpenProject..."); 73 | 74 | let allWorkPackages = []; 75 | let page = 1; 76 | const pageSize = 100; 77 | let total = null; 78 | const workPackageMap = new Map(); 79 | projectId = projectId?.toString().trim(); 80 | 81 | // #35: prepare potential filtering by one, several or all projects in OpenProject 82 | const opProjectFilter = 83 | !projectId || ["*", "all"].includes(projectId.toLowerCase()) 84 | ? [] // Pass an empty array for no filtering, otherwise OpenProject applies a default filter (status_id open https://www.openproject.org/docs/api/endpoints/work-packages/#list-work-packages) 85 | : [ 86 | { 87 | project: { 88 | operator: "=", 89 | values: projectId.split(","), 90 | }, 91 | }, 92 | ]; 93 | 94 | while (true) { 95 | console.log(`Fetching page ${page}...`); 96 | 97 | try { 98 | const response = await openProjectApi.get("/work_packages", { 99 | params: { 100 | filters: JSON.stringify(opProjectFilter), 101 | offset: page, 102 | pageSize: pageSize, 103 | sortBy: JSON.stringify([["id", "asc"]]), 104 | }, 105 | }); 106 | 107 | if (total === null) { 108 | total = parseInt(response.data.total); 109 | console.log(`Total work packages to fetch: ${total}`); 110 | } 111 | 112 | const workPackages = response.data._embedded.elements; 113 | if (!workPackages || workPackages.length === 0) { 114 | break; 115 | } 116 | 117 | // Log the first work package to see its structure 118 | if (page === 1) { 119 | console.log("\nExample work package structure:"); 120 | console.log(JSON.stringify(workPackages[0], null, 2)); 121 | } 122 | 123 | allWorkPackages = allWorkPackages.concat(workPackages); 124 | console.log( 125 | `Retrieved ${ 126 | allWorkPackages.length 127 | } of ${total} work packages (${Math.round( 128 | (allWorkPackages.length / total) * 100 129 | )}%)` 130 | ); 131 | 132 | // Map work packages by their Jira ID 133 | for (const wp of workPackages) { 134 | const jiraId = wp[`customField${JIRA_ID_CUSTOM_FIELD}`]; 135 | if (jiraId) { 136 | workPackageMap.set(jiraId, wp); 137 | } 138 | } 139 | 140 | if (allWorkPackages.length >= total) { 141 | break; 142 | } 143 | 144 | page++; 145 | // Add a small delay between requests 146 | await new Promise((resolve) => setTimeout(resolve, 100)); 147 | } catch (error) { 148 | console.error("Error fetching work packages:", error.message); 149 | throw error; 150 | } 151 | } 152 | 153 | console.log( 154 | `\nTotal work packages found in OpenProject: ${allWorkPackages.length}` 155 | ); 156 | 157 | // Log cache summary 158 | const withJiraId = Array.from(workPackageMap.keys()).length; 159 | const withoutJiraId = allWorkPackages.length - withJiraId; 160 | console.log("\nCache Summary:"); 161 | console.log(`- Total work packages: ${allWorkPackages.length}`); 162 | console.log(`- Work packages with Jira ID: ${withJiraId}`); 163 | console.log(`- Work packages without Jira ID: ${withoutJiraId}`); 164 | console.log(`- Cached ${withJiraId} work packages for quick lookup`); 165 | console.log("=======================================\n"); 166 | 167 | return workPackageMap; 168 | } 169 | 170 | async function setParentWorkPackage(childId, parentId) { 171 | try { 172 | // Get current work package to get its lock version 173 | const currentWP = await openProjectApi.get(`/work_packages/${childId}`); 174 | 175 | await openProjectApi.patch(`/work_packages/${childId}`, { 176 | lockVersion: currentWP.data.lockVersion, 177 | _links: { 178 | parent: { 179 | href: `/api/v3/work_packages/${parentId}`, 180 | }, 181 | }, 182 | }); 183 | } catch (error) { 184 | console.error( 185 | `Error setting parent for work package ${childId}:`, 186 | error.message 187 | ); 188 | if (error.response?.data) { 189 | console.error( 190 | "Error details:", 191 | JSON.stringify(error.response.data, null, 2) 192 | ); 193 | } 194 | throw error; 195 | } 196 | } 197 | 198 | async function createWorkPackage(projectId, payload) { 199 | try { 200 | const response = await openProjectApi.post("/work_packages", { 201 | ...payload, 202 | _links: { 203 | ...payload._links, 204 | project: { 205 | href: `/api/v3/projects/${projectId}`, 206 | }, 207 | }, 208 | }); 209 | return response.data; 210 | } catch (error) { 211 | console.error("Error creating work package:", error.message); 212 | throw error; 213 | } 214 | } 215 | 216 | async function updateWorkPackage(workPackageId, payload) { 217 | try { 218 | // Get current work package to get its lock version 219 | const currentWP = await openProjectApi.get( 220 | `/work_packages/${workPackageId}` 221 | ); 222 | 223 | // Remove _type from update payload and add lock version 224 | const { _type, ...updatePayload } = payload; 225 | updatePayload.lockVersion = currentWP.data.lockVersion; 226 | 227 | const response = await openProjectApi.patch( 228 | `/work_packages/${workPackageId}`, 229 | updatePayload 230 | ); 231 | return response.data; 232 | } catch (error) { 233 | console.error( 234 | `Error updating work package ${workPackageId}:`, 235 | error.message 236 | ); 237 | if (error.response?.data) { 238 | console.error( 239 | "Error details:", 240 | JSON.stringify(error.response.data, null, 2) 241 | ); 242 | } 243 | throw error; 244 | } 245 | } 246 | 247 | async function addComment(workPackageId, comment) { 248 | try { 249 | await openProjectApi.post(`/work_packages/${workPackageId}/activities`, { 250 | comment: { 251 | raw: Buffer.from(comment).toString("utf8"), 252 | }, 253 | }); 254 | } catch (error) { 255 | console.error( 256 | `Error adding comment to work package ${workPackageId}:`, 257 | error.message 258 | ); 259 | throw error; 260 | } 261 | } 262 | 263 | async function uploadAttachment(workPackageId, filePath, fileName, mimeType) { 264 | try { 265 | const formData = new FormData(); 266 | formData.append("metadata", JSON.stringify({ fileName })); 267 | formData.append("file", fs.createReadStream(filePath)); 268 | 269 | const response = await openProjectApi.post( 270 | `/work_packages/${workPackageId}/attachments`, 271 | formData, 272 | { 273 | headers: { 274 | ...openProjectConfig.headers, 275 | "Content-Type": "multipart/form-data", 276 | }, 277 | } 278 | ); 279 | return response.data; 280 | } catch (error) { 281 | console.error( 282 | `Error uploading attachment to work package ${workPackageId}:`, 283 | error.message 284 | ); 285 | throw error; 286 | } 287 | } 288 | 289 | async function addWatcher(workPackageId, userId) { 290 | try { 291 | console.log( 292 | `Adding watcher (userId: ${userId}) to work package ${workPackageId}...` 293 | ); 294 | await openProjectApi.post(`/work_packages/${workPackageId}/watchers`, { 295 | user: { href: `/api/v3/users/${userId}` }, 296 | }); 297 | console.log( 298 | `Successfully added watcher ${userId} to work package ${workPackageId}` 299 | ); 300 | } catch (error) { 301 | // Ignore if watcher already exists (409 Conflict) 302 | if (error.response?.status === 409) { 303 | console.log( 304 | `Watcher ${userId} is already watching work package ${workPackageId}` 305 | ); 306 | } else { 307 | console.error( 308 | `Error adding watcher ${userId} to work package ${workPackageId}:`, 309 | error.message 310 | ); 311 | if (error.response?.data) { 312 | console.error( 313 | "Error details:", 314 | JSON.stringify(error.response.data, null, 2) 315 | ); 316 | } 317 | if (error.response?.status === 404) { 318 | console.error( 319 | "This could mean either the work package or user doesn't exist" 320 | ); 321 | } else if (error.response?.status === 403) { 322 | console.error( 323 | "This could mean insufficient permissions to add watchers" 324 | ); 325 | } 326 | } 327 | } 328 | } 329 | 330 | async function listProjects() { 331 | try { 332 | const response = await openProjectApi.get("/projects"); 333 | console.log("\nAvailable OpenProject Projects:"); 334 | response.data._embedded.elements.forEach((project) => { 335 | console.log(`- ID: ${project.id}, Name: ${project.name}`); 336 | }); 337 | return response.data._embedded.elements; 338 | } catch (error) { 339 | console.error("Error listing projects:", error.message); 340 | throw error; 341 | } 342 | } 343 | 344 | async function getWorkPackageTypes() { 345 | try { 346 | const response = await openProjectApi.get("/types"); 347 | workPackageTypes = response.data._embedded.elements; 348 | console.log("\nAvailable work package types:"); 349 | workPackageTypes.forEach((type) => { 350 | console.log(`- ${type.name} (ID: ${type.id})`); 351 | }); 352 | return workPackageTypes; 353 | } catch (error) { 354 | console.error("Error fetching work package types:", error.message); 355 | throw error; 356 | } 357 | } 358 | 359 | async function getWorkPackageStatuses() { 360 | try { 361 | const response = await openProjectApi.get("/statuses"); 362 | workPackageStatuses = response.data._embedded.elements; 363 | console.log("\nAvailable work package statuses:"); 364 | workPackageStatuses.forEach((status) => { 365 | console.log(`- ${status.name} (ID: ${status.id})`); 366 | }); 367 | return workPackageStatuses; 368 | } catch (error) { 369 | console.error("Error fetching work package statuses:", error.message); 370 | throw error; 371 | } 372 | } 373 | 374 | async function getWorkPackagePriorities() { 375 | try { 376 | const response = await openProjectApi.get("/priorities"); 377 | workPackagePriorities = response.data._embedded.elements; 378 | console.log("\nAvailable work package priorities:"); 379 | workPackagePriorities.forEach((priority) => { 380 | console.log(`- ${priority.name} (ID: ${priority.id})`); 381 | }); 382 | return workPackagePriorities; 383 | } catch (error) { 384 | console.error("Error fetching work package priorities:", error.message); 385 | throw error; 386 | } 387 | } 388 | 389 | function getWorkPackageTypeId(jiraIssueType) { 390 | console.log(`Mapping Jira type: ${jiraIssueType}`); 391 | const mappedType = typeMapping[jiraIssueType] || "Task"; // Default to Task if no mapping found 392 | const typeObj = workPackageTypes.find( 393 | (t) => t.name.toLowerCase() === mappedType.toLowerCase() 394 | ); 395 | if (!typeObj) { 396 | console.warn( 397 | `Could not find OpenProject type for ${jiraIssueType} (mapped to ${mappedType})` 398 | ); 399 | return workPackageTypes[0].id; 400 | } 401 | console.log( 402 | `Mapped to OpenProject type: ${typeObj.name} (ID: ${typeObj.id})` 403 | ); 404 | return typeObj.id; 405 | } 406 | 407 | function getWorkPackageStatusId(jiraStatus) { 408 | console.log(`Mapping Jira status: ${jiraStatus}`); 409 | const mappedStatus = statusMapping[jiraStatus] || "New"; // Default to New if no mapping found 410 | const statusObj = workPackageStatuses.find( 411 | (s) => s.name.toLowerCase() === mappedStatus.toLowerCase() 412 | ); 413 | if (!statusObj) { 414 | console.warn( 415 | `Could not find OpenProject status for ${jiraStatus} (mapped to ${mappedStatus})` 416 | ); 417 | return workPackageStatuses[0].id; // Default to first status 418 | } 419 | console.log( 420 | `Mapped to OpenProject status: ${statusObj.name} (ID: ${statusObj.id})` 421 | ); 422 | return statusObj.id; 423 | } 424 | 425 | function getWorkPackagePriorityId(jiraPriority) { 426 | if (!jiraPriority) return null; 427 | 428 | console.log(`Mapping Jira priority: ${jiraPriority.name}`); 429 | const mappedPriority = priorityMapping[jiraPriority.name] || "Normal"; // Default to Normal if no mapping found 430 | const priorityObj = workPackagePriorities.find( 431 | (p) => p.name.toLowerCase() === mappedPriority.toLowerCase() 432 | ); 433 | if (!priorityObj) { 434 | console.warn( 435 | `Could not find OpenProject priority for ${jiraPriority.name} (mapped to ${mappedPriority})` 436 | ); 437 | return workPackagePriorities.find((p) => p.isDefault)?.id; // Default to the default priority 438 | } 439 | console.log( 440 | `Mapped to OpenProject priority: ${priorityObj.name} (ID: ${priorityObj.id})` 441 | ); 442 | return priorityObj.id; 443 | } 444 | 445 | async function getExistingAttachments(workPackageId) { 446 | try { 447 | const response = await openProjectApi.get( 448 | `/work_packages/${workPackageId}/attachments` 449 | ); 450 | return response.data._embedded.elements; 451 | } catch (error) { 452 | console.error(`Error getting existing attachments: ${error.message}`); 453 | return []; 454 | } 455 | } 456 | 457 | async function getExistingComments(workPackageId) { 458 | try { 459 | const response = await openProjectApi.get( 460 | `/work_packages/${workPackageId}/activities` 461 | ); 462 | return response.data._embedded.elements.filter((e) => e.comment?.raw); 463 | } catch (error) { 464 | console.error(`Error getting existing comments: ${error.message}`); 465 | return []; 466 | } 467 | } 468 | 469 | async function getOpenProjectUsers() { 470 | try { 471 | const response = await openProjectApi.get("/users"); 472 | openProjectUsers = response.data._embedded.elements; 473 | console.log("\nAvailable OpenProject users:"); 474 | openProjectUsers.forEach((user) => { 475 | console.log(`- ${user.name} (ID: ${user.id}, Email: ${user.email})`); 476 | }); 477 | return openProjectUsers; 478 | } catch (error) { 479 | console.error("Error fetching OpenProject users:", error.message); 480 | throw error; 481 | } 482 | } 483 | 484 | async function findExistingWorkPackage(jiraKey, projectId) { 485 | try { 486 | const response = await openProjectApi.get("/work_packages", { 487 | params: { 488 | filters: JSON.stringify([ 489 | { project: { operator: "=", values: [projectId.toString()] } }, 490 | { 491 | [`customField${JIRA_ID_CUSTOM_FIELD}`]: { 492 | operator: "=", 493 | values: [jiraKey], 494 | }, 495 | }, 496 | ]), 497 | }, 498 | }); 499 | 500 | const workPackages = response.data._embedded.elements; 501 | return workPackages.length > 0 ? workPackages[0] : null; 502 | } catch (error) { 503 | console.error(`Error finding existing work package: ${error.message}`); 504 | if (error.response?.data) { 505 | console.error( 506 | "Error details:", 507 | JSON.stringify(error.response.data, null, 2) 508 | ); 509 | } 510 | return null; 511 | } 512 | } 513 | 514 | function getWorkPackageTypeName(typeId) { 515 | const type = workPackageTypes?.find((t) => t.id === typeId); 516 | return type ? type.name : "Unknown"; 517 | } 518 | 519 | function getWorkPackageStatusName(statusId) { 520 | const status = workPackageStatuses?.find((s) => s.id === statusId); 521 | return status ? status.name : "Unknown"; 522 | } 523 | 524 | module.exports = { 525 | getOpenProjectWorkPackages, 526 | setParentWorkPackage, 527 | createWorkPackage, 528 | updateWorkPackage, 529 | addComment, 530 | uploadAttachment, 531 | addWatcher, 532 | listProjects, 533 | getWorkPackageTypes, 534 | getWorkPackageStatuses, 535 | getWorkPackagePriorities, 536 | getWorkPackageTypeId, 537 | getWorkPackageStatusId, 538 | getWorkPackagePriorityId, 539 | getExistingAttachments, 540 | getExistingComments, 541 | getOpenProjectUsers, 542 | findExistingWorkPackage, 543 | getWorkPackageTypeName, 544 | getWorkPackageStatusName, 545 | typeMapping, 546 | statusMapping, 547 | priorityMapping, 548 | JIRA_ID_CUSTOM_FIELD, 549 | }; 550 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const inquirer = require("inquirer"); 5 | const { 6 | getAllJiraIssues, 7 | getSpecificJiraIssues, 8 | downloadAttachment, 9 | listProjects, 10 | getIssueWatchers, 11 | } = require("./jira-client"); 12 | const { generateMapping } = require("./generate-user-mapping"); 13 | const { 14 | getOpenProjectWorkPackages, 15 | createWorkPackage, 16 | updateWorkPackage, 17 | addComment, 18 | uploadAttachment, 19 | getWorkPackageTypes, 20 | getWorkPackageStatuses, 21 | getWorkPackageTypeId, 22 | getWorkPackageStatusId, 23 | getExistingAttachments, 24 | getExistingComments, 25 | getOpenProjectUsers, 26 | findExistingWorkPackage, 27 | JIRA_ID_CUSTOM_FIELD, 28 | getWorkPackagePriorityId, 29 | getWorkPackagePriorities, 30 | addWatcher, 31 | } = require("./openproject-client"); 32 | const { default: parse } = require("node-html-parser"); 33 | 34 | // Create temp directory for attachments if it doesn't exist 35 | const tempDir = path.join(__dirname, "temp"); 36 | if (!fs.existsSync(tempDir)) { 37 | fs.mkdirSync(tempDir, { recursive: true }); 38 | } 39 | 40 | let userMapping = null; 41 | 42 | async function getOpenProjectUserId(jiraUser) { 43 | if (!jiraUser) { 44 | console.log("No Jira user provided"); 45 | return null; 46 | } 47 | 48 | const openProjectUserId = userMapping[jiraUser.accountId]; 49 | if (openProjectUserId) { 50 | console.log( 51 | `Found OpenProject user ID ${openProjectUserId} for Jira user ${jiraUser.displayName}` 52 | ); 53 | return openProjectUserId; 54 | } 55 | 56 | console.log( 57 | `No OpenProject user mapping found for Jira user ${jiraUser.displayName}` 58 | ); 59 | return null; 60 | } 61 | 62 | async function migrateIssues( 63 | jiraProjectKey, 64 | openProjectId, 65 | isProd, 66 | specificIssues, 67 | skipUpdates, 68 | mapResponsible 69 | ) { 70 | console.log( 71 | `Starting migration for project ${jiraProjectKey} to OpenProject project ${openProjectId}` 72 | ); 73 | console.log("Production mode:", isProd ? "yes" : "no"); 74 | console.log( 75 | "Map Jira creator to OpenProject accountable:", 76 | mapResponsible ? "yes" : "no" 77 | ); 78 | 79 | // Generate or load user mapping 80 | console.log("\nChecking user mapping..."); 81 | try { 82 | userMapping = require("./user-mapping"); 83 | const shouldUpdate = await inquirer.prompt([ 84 | { 85 | type: "confirm", 86 | name: "update", 87 | message: "Existing user mapping found. Would you like to update it?", 88 | default: false, 89 | }, 90 | ]); 91 | if (shouldUpdate.update) { 92 | userMapping = await generateMapping(); 93 | } 94 | } catch (error) { 95 | console.log("No existing user mapping found. Generating new mapping..."); 96 | userMapping = await generateMapping(); 97 | } 98 | 99 | // List available projects 100 | await listProjects(); 101 | 102 | // Get work package types and statuses 103 | await getWorkPackageTypes(); 104 | await getWorkPackageStatuses(); 105 | await getWorkPackagePriorities(); 106 | await getOpenProjectUsers(); 107 | 108 | // Cache OpenProject work packages if skipUpdates is enabled 109 | let openProjectWorkPackagesCache = null; 110 | if (skipUpdates) { 111 | console.log("Caching OpenProject work packages..."); 112 | openProjectWorkPackagesCache = await getOpenProjectWorkPackages( 113 | openProjectId 114 | ); 115 | console.log( 116 | `Found ${openProjectWorkPackagesCache.size} work packages in OpenProject` 117 | ); 118 | } 119 | 120 | // Get Jira issues 121 | const jiraIssues = specificIssues 122 | ? await getSpecificJiraIssues(jiraProjectKey, specificIssues) 123 | : await getAllJiraIssues(jiraProjectKey); 124 | 125 | console.log(`Found ${jiraIssues.length} Jira issues to process`); 126 | console.log("Issues will be processed in chronological order (oldest first)"); 127 | 128 | // Process each issue 129 | let processed = 0; 130 | let skipped = 0; 131 | let errors = 0; 132 | const issueToWorkPackageMap = new Map(); 133 | 134 | for (const issue of jiraIssues) { 135 | try { 136 | console.log(`\nProcessing ${issue.key}...`); 137 | 138 | // Check if work package already exists 139 | let existingWorkPackage = null; 140 | if (skipUpdates) { 141 | existingWorkPackage = openProjectWorkPackagesCache.get(issue.key); 142 | } else { 143 | existingWorkPackage = await findExistingWorkPackage( 144 | issue.key, 145 | openProjectId 146 | ); 147 | } 148 | 149 | if (existingWorkPackage && skipUpdates) { 150 | console.log( 151 | `Skipping ${issue.key} - already exists as work package ${existingWorkPackage.id}` 152 | ); 153 | issueToWorkPackageMap.set(issue.key, existingWorkPackage.id); 154 | skipped++; 155 | continue; 156 | } 157 | 158 | // Get assignee ID from mapping 159 | let assigneeId = null; 160 | let responsibleId = null; 161 | if (issue.fields.assignee) { 162 | assigneeId = await getOpenProjectUserId(issue.fields.assignee); 163 | } 164 | if (mapResponsible && issue.fields.creator) { 165 | responsibleId = await getOpenProjectUserId(issue.fields.creator); 166 | } 167 | 168 | // Create work package payload 169 | const rawDescription = Buffer.from( 170 | convertAtlassianDocumentToText( 171 | // #22: prefer HTML rendered content if available 172 | issue.renderedFields?.description ?? issue.fields.description 173 | ) 174 | ).toString("utf8"); 175 | const payload = { 176 | _type: "WorkPackage", 177 | subject: issue.fields.summary, 178 | description: { 179 | raw: rawDescription, 180 | }, 181 | _links: { 182 | type: { 183 | href: `/api/v3/types/${getWorkPackageTypeId( 184 | issue.fields.issuetype.name 185 | )}`, 186 | }, 187 | status: { 188 | href: `/api/v3/statuses/${getWorkPackageStatusId( 189 | issue.fields.status.name 190 | )}`, 191 | }, 192 | priority: { 193 | href: `/api/v3/priorities/${getWorkPackagePriorityId( 194 | issue.fields.priority 195 | )}`, 196 | }, 197 | project: { 198 | href: `/api/v3/projects/${openProjectId}`, 199 | }, 200 | }, 201 | [`customField${JIRA_ID_CUSTOM_FIELD}`]: issue.key, 202 | }; 203 | 204 | // Add assignee if available 205 | if (assigneeId) { 206 | payload._links.assignee = { 207 | href: `/api/v3/users/${assigneeId}`, 208 | }; 209 | } 210 | 211 | // Add responsible (accountable) if available 212 | if (responsibleId) { 213 | payload._links.responsible = { 214 | href: `/api/v3/users/${responsibleId}`, 215 | }; 216 | } 217 | 218 | let workPackage; 219 | const hasAttachments = 220 | issue.fields.attachment && issue.fields.attachment.length > 0; 221 | if (existingWorkPackage) { 222 | console.log(`Updating existing work package ${existingWorkPackage.id}`); 223 | // In case there are attachments, do not update description yet, as it will be reworked later 224 | if (hasAttachments) { 225 | delete payload.description; 226 | } 227 | workPackage = await updateWorkPackage(existingWorkPackage.id, payload); 228 | } else { 229 | console.log("Creating new work package"); 230 | workPackage = await createWorkPackage(openProjectId, payload); 231 | } 232 | 233 | issueToWorkPackageMap.set(issue.key, workPackage.id); 234 | 235 | // Process attachments 236 | /** Keep reference of attachments to be able to rework description and comments */ 237 | let attachmentsByJiraId = {}; 238 | if (hasAttachments) { 239 | const existingAttachments = await getExistingAttachments( 240 | workPackage.id 241 | ); 242 | const existingAttachmentsByFileName = {}; 243 | for (const attachment of existingAttachments) { 244 | existingAttachmentsByFileName[attachment.fileName] = attachment; 245 | } 246 | 247 | for (const jiraAttachment of issue.fields.attachment) { 248 | const sanitizedFileName = sanitizeFileName(jiraAttachment.filename); 249 | let opAttachment; 250 | if (existingAttachmentsByFileName[sanitizedFileName]) { 251 | console.log(`Skipping existing attachment: ${sanitizedFileName}`); 252 | opAttachment = existingAttachmentsByFileName[sanitizedFileName]; 253 | } else { 254 | console.log( 255 | `Processing attachment: ${jiraAttachment.filename}${ 256 | jiraAttachment.filename !== sanitizedFileName 257 | ? ` (sanitized to: ${sanitizedFileName})` 258 | : "" 259 | }` 260 | ); 261 | const tempFilePath = path.join(tempDir, jiraAttachment.filename); 262 | await downloadAttachment(jiraAttachment.content, tempFilePath); 263 | opAttachment = await uploadAttachment( 264 | workPackage.id, 265 | tempFilePath, 266 | jiraAttachment.filename 267 | ); 268 | fs.unlinkSync(tempFilePath); 269 | } 270 | 271 | // #14: keep track of uploaded attachment by Jira ID 272 | attachmentsByJiraId[jiraAttachment.id] = { 273 | jiraAttachment, 274 | opAttachment, 275 | }; 276 | } 277 | 278 | // #14: Update work package description with fixed attachment links 279 | const updatedDescription = fixAttachmentsInHTML( 280 | rawDescription, 281 | attachmentsByJiraId 282 | ); 283 | if ( 284 | updatedDescription !== 285 | (existingWorkPackage?.description?.raw ?? rawDescription) 286 | ) { 287 | console.log( 288 | "Updating work package description with attachment links" 289 | ); 290 | await updateWorkPackage(workPackage.id, { 291 | description: { raw: updatedDescription }, 292 | }); 293 | } 294 | } 295 | 296 | // Process comments 297 | // #22: prefer HTML rendered content if available 298 | const fieldsCommentsData = issue.fields.comment; 299 | const renderedCommentsData = issue.renderedFields?.comment; 300 | const commentsData = renderedCommentsData ?? fieldsCommentsData; 301 | 302 | // #26: in case using renderedFields, overwrite dates to maintain original format 303 | if (commentsData === renderedCommentsData) { 304 | // Optimize lookup by creating a map of comment IDs to original comments 305 | const fieldsCommentsById = {}; 306 | for (const comment of fieldsCommentsData.comments) { 307 | fieldsCommentsById[comment.id] = comment; 308 | } 309 | for (const comment of commentsData.comments) { 310 | const originalComment = fieldsCommentsById[comment.id]; 311 | if (originalComment) { 312 | comment.created = originalComment.created; 313 | comment.updated = originalComment.updated; 314 | } 315 | } 316 | } 317 | 318 | if (commentsData && commentsData.comments.length > 0) { 319 | const existingComments = await getExistingComments(workPackage.id); 320 | const existingCommentTexts = existingComments.map((c) => c.comment.raw); 321 | 322 | for (const comment of commentsData.comments) { 323 | const commentText = convertAtlassianDocumentToText(comment.body); 324 | if (commentText) { 325 | const preambledComment = `${ 326 | comment.author.displayName 327 | } wrote on ${new Date( 328 | comment.created 329 | ).toLocaleString()}:\n${commentText}`; 330 | 331 | // #14: rework attachment links in comments 332 | const formattedComment = fixAttachmentsInHTML( 333 | preambledComment, 334 | attachmentsByJiraId 335 | ); 336 | 337 | if (existingCommentTexts.includes(formattedComment)) { 338 | console.log("Skipping existing comment"); 339 | continue; 340 | } 341 | 342 | console.log("Adding comment"); 343 | await addComment(workPackage.id, formattedComment); 344 | } 345 | } 346 | } 347 | 348 | // Add watchers if any 349 | if (issue.fields.watches?.watchCount > 0) { 350 | console.log("Adding watchers"); 351 | const watchers = await getIssueWatchers(issue.key); 352 | for (const watcher of watchers.watchers) { 353 | const watcherId = await getOpenProjectUserId(watcher); 354 | if (watcherId) { 355 | await addWatcher(workPackage.id, watcherId); 356 | } 357 | } 358 | } 359 | 360 | processed++; 361 | } catch (error) { 362 | console.error(`Error processing ${issue.key}:`, error.message); 363 | if (error.response?.data) { 364 | console.error( 365 | "Error details:", 366 | JSON.stringify(error.response.data, null, 2) 367 | ); 368 | } 369 | errors++; 370 | } 371 | } 372 | 373 | // Clean up temp directory 374 | if (fs.existsSync(tempDir)) { 375 | fs.rmSync(tempDir, { recursive: true }); 376 | } 377 | 378 | console.log("\nMigration summary:"); 379 | console.log(`Total issues processed: ${processed + skipped}`); 380 | console.log(`Completed: ${processed}`); 381 | console.log(`Skipped: ${skipped}`); 382 | console.log(`Errors: ${errors}`); 383 | 384 | return issueToWorkPackageMap; 385 | } 386 | 387 | function convertAtlassianDocumentToText(document) { 388 | if (!document) return ""; 389 | if (typeof document === "string") return document; 390 | 391 | try { 392 | if (document.content) { 393 | return document.content 394 | .map((block) => block.content?.map((c) => c.text).join("") || "") 395 | .join("\n") 396 | .trim(); 397 | } 398 | return ""; 399 | } catch (error) { 400 | console.error("Error converting Atlassian document:", error); 401 | return ""; 402 | } 403 | } 404 | 405 | /** 406 | * #14: Fixes attachment references in HTML content by updating image sources and link hrefs 407 | * based on a mapping of Jira attachment IDs. For images, it also sets or overwrites the alt attribute 408 | * with the attachment filename and optionally returns a note about the original alt text. 409 | * 410 | * @param {string} html - The HTML string containing attachment references to be fixed. 411 | * @param {Object} attachmentsByJiraId - An object mapping Jira attachment IDs to attachment objects, 412 | * where each attachment object has at least a 'filename' property. 413 | * @returns {string} The modified HTML string with updated attachment references. 414 | */ 415 | function fixAttachmentsInHTML(html, attachmentsByJiraId) { 416 | const root = parse(html); 417 | root.querySelectorAll("img").forEach((img) => { 418 | reworkElement( 419 | img, 420 | "src", 421 | fixAttachmentsInHTML.regex, 422 | attachmentsByJiraId, 423 | (img, jiraAttachment) => { 424 | const originalAlt = img.getAttribute("alt"); 425 | // #29: set alt attribute (in case it is missing, but we can always overwrite) 426 | img.setAttribute("alt", jiraAttachment.filename); 427 | if (originalAlt) return `Original alt: ${originalAlt}\n`; 428 | } 429 | ); 430 | }); 431 | // Also fix links to attachments that are not images 432 | root.querySelectorAll("a").forEach((a) => { 433 | reworkElement(a, "href", fixAttachmentsInHTML.regex, attachmentsByJiraId); 434 | }); 435 | return root.toString(); 436 | } 437 | fixAttachmentsInHTML.regex = /\/attachment\/content\/(\d+)$/; 438 | 439 | /** 440 | * Reworks a DOM element by updating a specified attribute with an OpenProject attachment link 441 | * based on a regex match against the attribute's original value. If a match is found and a corresponding 442 | * attachment pair exists, the attribute is set to the OpenProject download link, and the element's 443 | * title is updated to include original information and optionally custom content from a callback. 444 | * 445 | * @param {Element} element - The DOM element to modify. 446 | * @param {string} attribute - The name of the attribute to update (e.g., 'href' or 'src'). 447 | * @param {RegExp} regex - The regular expression to match against the attribute's original value, 448 | * expected to capture the Jira attachment ID in the first group. 449 | * @param {Object.} attachmentsByJiraId - 450 | * A map of Jira attachment IDs to objects containing Jira and OpenProject attachment details. 451 | * @param {Function|null} [buildTitleCb=null] - An optional callback function to build additional title content. 452 | * It receives the element and jiraAttachment as arguments and should return a string. 453 | */ 454 | function reworkElement( 455 | element, 456 | attribute, 457 | regex, 458 | attachmentsByJiraId, 459 | buildTitleCb = null 460 | ) { 461 | const originalValue = element.getAttribute(attribute); 462 | const match = originalValue?.match(regex); 463 | if (!match) return; 464 | 465 | const jiraAttachmentId = match[1]; 466 | const attachmentPair = attachmentsByJiraId[jiraAttachmentId]; 467 | if (!attachmentPair) return; 468 | 469 | const { jiraAttachment, opAttachment } = attachmentPair; 470 | // #14: update attribute with OpenProject attachment link 471 | element.setAttribute( 472 | attribute, 473 | opAttachment._links.staticDownloadLocation.href 474 | ); 475 | // Also archive the original value just in case 476 | const originalTitle = element.getAttribute("title"); 477 | element.setAttribute( 478 | "title", 479 | `${originalTitle ? `Original title: ${originalTitle}\n` : ""}${ 480 | buildTitleCb?.(element, jiraAttachment) ?? "" 481 | }Original file name: ${ 482 | jiraAttachment.filename 483 | }\nOriginal ${attribute}: ${originalValue}` 484 | ); 485 | } 486 | 487 | /** 488 | * #24: Sanitizes a file name by replacing invalid characters with underscores. 489 | * Replicates CarrierWave's sanitize_regexp behavior to match name after upload. 490 | * 491 | * @param {string} fileName - The file name to sanitize 492 | * @returns {string} The sanitized file name with invalid characters replaced by underscores 493 | * @see {@link https://github.com/carrierwaveuploader/carrierwave/blob/v3.1.2/lib/carrierwave/sanitized_file.rb#L23} 494 | * 495 | * @example 496 | * sanitizeFileName("my file (1).txt") // Returns: "my_file__1_.txt" 497 | * sanitizeFileName("document+v2.0.pdf") // Returns: "document+v2.0.pdf" 498 | */ 499 | function sanitizeFileName(fileName) { 500 | return fileName.replace( 501 | // `v` flag for Unicode support, `i` for case-insensitivity, `g` for global replacement 502 | // Unfortunately, `\w` behaves differently in JavaScript compared to Ruby, so we explicitly define allowed characters 503 | // https://ruby-doc.org/3.4.1/Regexp.html#class-Regexp-label-POSIX+Bracket+Expressions 504 | // https://unicode.org/reports/tr18/#General_Category_Property 505 | // https://unicode.org/reports/tr18/#alpha 506 | // https://unicode.org/reports/tr44/#Join_Control 507 | /[^\p{Mark}\p{Decimal_Number}\p{Connector_Punctuation}\p{Alpha}\p{Join_Control}\.\-\+]/giv, 508 | "_" 509 | ); 510 | } 511 | 512 | module.exports = { 513 | migrateIssues, 514 | }; 515 | --------------------------------------------------------------------------------