Skip to content

Instantly share code, notes, and snippets.

@mukhortov
Last active April 15, 2024 06:11
Show Gist options
  • Save mukhortov/c33b717491addc294d2c1abda422db2d to your computer and use it in GitHub Desktop.
Save mukhortov/c33b717491addc294d2c1abda422db2d to your computer and use it in GitHub Desktop.
Migrate GitHub issues with comments and labels to Jira

Migrate GitHub issues with comments and labels to Jira

Set up the environment

  1. Instal Deno. It's a secure runtime for JavaScript and TypeScript.
  2. Generate a personal access token for GitHub. And save it to ~/.github-oauth. You can use the following command to save it:
    echo generated_github_personal_access_token_goes_here > ~/.github-oauth

Run migration scripts

  1. First we need to pull all the issues from GitHub. For that we will run github.ts script using Deno. Replace your_org_name/your_repo_name with your organization and repository names separated with /:

    deno run --allow-write --allow-net=api.github.com ./github.ts -t $(cat ~/.github-oauth) -r your_org_name/your_repo_name

    To see more options run the script with -h argument: deno ./github.ts -h

    The script creates two JSON files. One with GitHub issues (./github-issues.json) and another one with a template for mapping GitHub user IDs to the Jira user IDs (./github-user-map-template.json).

  2. Open autogenerated GitHub user map template file (by default the filename is ./github-user-map-template.json) and add Jira user IDs. At least the default has to be specified, it's used to fallback if no matching users found in Jira. After editing, I would recommend renaming the user map file, for example to ./github-user-map.json

  3. Now we can generate an import JSON file for Jira using ./jira.ts script. Replace ABC with your Jira project key:

    deno run --allow-read --allow-write ./jira.ts -i ./github-issues.json -u ./github-user-map.json -k ABC

    To see more options run the script with -h argument: deno ./jira.ts -h

This will generate an import JSON file for Jira, by default it would create ./jira-project.json. I would recommend visually validating the output JSON before importing it.

  1. Import the issues using the generated ./jira-project.json file. For more information on how to perform the import read Jira documentation.

Useful links

// Usage example
// -------------------------------------------------------------------------------------------
//
// deno run --allow-write --allow-net=api.github.com ./github.ts -t $(cat ~/.github-oauth) -r org/repo
//
// GitHub Api Documentation
// -------------------------------------------------------------------------------------------
//
// https://docs.github.com/en/rest/reference/issues#list-repository-issues
//
import { parse } from 'https://deno.land/std/flags/mod.ts'
import { errorLog, successLog, warningLog } from 'https://deno.land/x/colorlog/mod.ts'
// Arguments
// -------------------------------------------------------------------------------------------
const args = parse(Deno.args)
const moreHelp = () => warningLog('For more help run with -h\n')
if (args.h) {
console.info('\n')
console.info('OPTIONS:')
console.info(' -h Help')
console.info(' -t personal_access_token Add GitHub personal access token')
console.info(' -r org_name/repo_name Add org and repo names separated with /')
console.info(' -o ./github-issues.json Optional. Specify GitHub issues output path')
console.info(' -u ./user-map.json Optional. Specify user map output path')
console.info(' -p number_of_pages Optional. Number of pages (100 issues per page). Default value: 10')
console.info('\n')
console.info('USAGE:')
console.info(
' deno run --allow-write --allow-net=api.github.com ./github.ts -t $(cat ~/.github-oauth) -r org_name/repo_name',
)
console.info('\n')
Deno.exit(1)
}
if (!args.t) {
errorLog('\n\nError: Missing GitHub personal access token.\n')
warningLog('Pass it with a -t parameter: -t personal_access_token\n')
moreHelp()
Deno.exit(1)
}
if (!args.r || !args.r.includes('/')) {
errorLog('\n\nError: Missing or invalid org and repo names.\n')
warningLog('Pass it with a -r parameter: -r org_name/repo_name\n')
moreHelp()
Deno.exit(1)
}
if (args.p && isNaN(args.p)) {
errorLog('\n\nError: Invalid -p argument value.\n')
warningLog('Expecting a number: -p 10\n')
moreHelp()
Deno.exit(1)
}
// Utils
// -------------------------------------------------------------------------------------------
function issuesUrl(page: number) {
return `https://api.github.com/repos/${args.r}/issues?per_page=100&page=${page}`
}
function fetchIssuesPage(pageNumber: number) {
const page = fetch(issuesUrl(pageNumber), {
method: 'GET',
headers: {
Authorization: `token ${args.t} `,
},
}).then(r => r.json())
return page
}
async function fetchComments(url: string) {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `token ${args.t} `,
},
})
const comments = await response.json()
return comments
}
// Execution
// -------------------------------------------------------------------------------------------
console.time('Execution time')
console.info('\nFetching issues...')
const numberOfPages = args.p || 10
const iterations = Array.from({ length: numberOfPages }, (_, i) => i + 1)
const issuesPages = iterations.map(pageNumber => fetchIssuesPage(pageNumber))
const issuesPagesArray = await Promise.all(issuesPages)
const issues = issuesPagesArray.flat(1)
successLog(`Fetched ${issues.length} issues`)
console.info('\n\nFetching comments...')
const withComments = issues.map(async issue => ({
...issue,
comments: await fetchComments(issue.comments_url),
}))
const results = await Promise.all(withComments)
const numberOfComments = results.map(i => i.comments.length).reduce((a, b) => a + b, 0)
successLog(`Fetched total ${numberOfComments} comments in ${results.length} issues`)
const outputFilename = args.o || './github-issues.json'
await Deno.writeTextFile(outputFilename, JSON.stringify(results)).then(() => {
successLog(`The results are saved to ${outputFilename}`)
})
// Creating user map template
// -------------------------------------------------------------------------------------------
console.info('\n\nFetching users...')
const githubUsersOutputFilename = args.u || './github-user-map-template.json'
const ghUsers = [
...new Set(
results
.map(issue => {
const commentUsers = issue.comments.length > 0 ? issue.comments.map((comment: any) => comment.user.login) : []
return [issue.user.login, ...commentUsers]
})
.flat(),
),
] as string[]
successLog(`Found ${ghUsers.length} unique GitHub users in issues and comments`)
const userMap = ghUsers.reduce((acc, cur) => ({ ...acc, [cur]: '' }), { default: 'default_jira_user_id' })
await Deno.writeTextFile(githubUsersOutputFilename, JSON.stringify(userMap)).then(() => {
successLog(`GitHub user map template is saved to ${githubUsersOutputFilename}\n`)
warningLog(
`(i) Add corresponding Jira user IDs for each GitHub username to the output JSON file before running Jira project creation script.`,
)
console.log(`Format:
{
"default": "default_jira_user_id",
"github_username": "jira_user_id"
}`)
console.info('"default" is used to fallback when the GitHub user is not in Jira\n')
console.timeEnd('Execution time')
})
// deno run --allow-read --allow-write ./jira.ts -i ./github-issues.json -u ./github-user-map.json -k ABC
// Jira API Documentation
// -------------------------------------------------------------------------------------------
// https://support.atlassian.com/jira-cloud-administration/docs/import-data-from-json/
///
import { parse } from 'https://deno.land/std/flags/mod.ts'
import { errorLog, successLog, warningLog } from 'https://deno.land/x/colorlog/mod.ts'
// Arguments
// -------------------------------------------------------------------------------------------
const args = parse(Deno.args)
if (args.h) {
console.info('\n')
console.info('OPTIONS:')
console.info(' -h Help')
console.info(' -i ./path_to_json_file.json Specify path to the file with GitHub issues JSON file.')
console.info(' -k JIRA_PROJECT_KEY Specify Jira project key')
console.info(' -u ./path_to_json_file.json Specify path to user map JSON file. Format:')
console.info(` {
"default": "default_jira_user_id",
"github_username": "jira_user_id"
}`)
console.info(' "default" is used to fallback when the GitHub user is not in Jira')
console.info(' -o ./output_file_path.json Optional. Specify and output path')
console.info('\n')
console.info('USAGE:')
console.info(
' deno run --allow-read --allow-write ./jira.ts -i ./github-issues.json -u ./github-user-map.json -k ABC',
)
console.info('\n')
Deno.exit(1)
}
if (!args.i) {
errorLog('\n\nError: Missing GitHub issues JSON file.\n')
warningLog('Pass it with a -i parameter: -i ./path_to_json_file.json\n')
warningLog('For more help run with -h\n')
Deno.exit(1)
}
if (!args.k) {
errorLog('\n\nError: Missing Jira Project Key\n')
warningLog('Pass it with a -k parameter: -k ABC\n')
warningLog('For more help run with -h\n')
Deno.exit(1)
}
if (!args.u) {
errorLog('\n\nError: Missing GitHub user map JSON file.\n')
warningLog('Pass it with a -u parameter: -u ./path_to_json_file.json\n')
warningLog('For more help run with -h\n')
Deno.exit(1)
}
// Model
// -------------------------------------------------------------------------------------------
interface UserMap {
[key: string]: string
}
interface GitHubUser {
login: string
}
interface GitHubLabel {
name: string
}
interface GitHubComment {
user: GitHubUser
created_at: string
body: string
}
interface GithubIssue {
number: number
url: string
title: string
user: GitHubUser
labels: GitHubLabel[]
comments: GitHubComment[]
created_at: string
body: string
}
// Utils
// -------------------------------------------------------------------------------------------
function readJsonSync(filePath: string): unknown {
const content = Deno.readTextFileSync(filePath)
try {
return JSON.parse(content)
} catch (err) {
err.message = `${filePath}: ${err.message}`
throw err
}
}
function mapUser(ghUsername: string, userMap: UserMap) {
return userMap[ghUsername] ? userMap[ghUsername] : userMap['default']
}
function createJiraLabels(ghLabels: GitHubLabel[]) {
return ghLabels.map(label => label.name)
}
function createJiraCustomFields(ghIssueUrl: string) {
return [
{
fieldName: 'GitHub Issue URL',
fieldType: 'com.atlassian.jira.plugin.system.customfieldtypes:url',
value: ghIssueUrl,
},
]
}
function convertMarkdown(string: string | null) {
// Jira documentation
// https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=images
return string
? string
.replaceAll(/(?:!\[(.*?)\]\((.*?)\))/g, '!$2!')
.replaceAll(/(###### )/g, 'h6. ')
.replaceAll(/(##### )/g, 'h5. ')
.replaceAll(/(#### )/g, 'h4. ')
.replaceAll(/(### )/g, 'h3. ')
.replaceAll(/(## )/g, 'h2. ')
.replaceAll(/(# )/g, 'h1. ')
.replaceAll(/\*\*/g, '*')
: ''
}
function createJiraComments(ghComments: GitHubComment[], userMap: UserMap) {
return ghComments.map(comment => ({
body: convertMarkdown(comment.body),
author: mapUser(comment.user.login, userMap),
created: comment.created_at,
}))
}
function createIssueType(ghLabels: GitHubLabel[]) {
return ghLabels.find(label => label.name === 'type: Bug') !== undefined ? 'Bug' : 'Task'
}
function createJiraIssue(id: number, ghIssue: GithubIssue, userMap: UserMap) {
return {
externalId: `${id}`,
issueType: createIssueType(ghIssue.labels),
created: ghIssue.created_at,
summary: ghIssue.title,
description: convertMarkdown(ghIssue.body),
reporter: mapUser(ghIssue.user.login, userMap),
labels: createJiraLabels(ghIssue.labels),
customFieldValues: createJiraCustomFields(ghIssue.url),
comments: createJiraComments(ghIssue.comments, userMap),
}
}
function createJiraProject(ghIssues: GithubIssue[], userMap: UserMap) {
return {
projects: [
{
name: 'GitHub Issues',
key: args.k,
issues: ghIssues.map((issue, i) => createJiraIssue(i + 1, issue, userMap)),
},
],
}
}
// Execution
// -------------------------------------------------------------------------------------------
console.time('Execution time')
const jiraProjectOutputFilename = args.o || './jira-project.json'
const ghIssues = readJsonSync(args.i) as GithubIssue[]
const userMap = readJsonSync(args.u) as UserMap
if (!userMap.default || userMap.default.trim().length <= 1) {
errorLog('\n\nError: Missing default Jira user ID\n')
warningLog(`Edit ${args.u} file\n`)
warningLog('For more help run with -h\n')
Deno.exit(1)
}
const jiraProject = createJiraProject(ghIssues, userMap)
await Deno.writeTextFile(jiraProjectOutputFilename, JSON.stringify(jiraProject)).then(() => {
successLog(`Jira project is saved to ${jiraProjectOutputFilename}\n`)
console.timeEnd('Execution time')
})
@shooking
Copy link

Great stuff!

@Shamsidinkhon
Copy link

Thank you for sharing!

@melvin4u445
Copy link

This was very handy and works like a charm! Thanks, @mukhortov :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment