Skip to content

Instantly share code, notes, and snippets.

@Gwerlas
Last active November 22, 2023 12:51
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save Gwerlas/980141404bccfa0b0c1d49f580c2d494 to your computer and use it in GitHub Desktop.
Save Gwerlas/980141404bccfa0b0c1d49f580c2d494 to your computer and use it in GitHub Desktop.
Migrate Jira issues to Gitlab
import requests
from requests.auth import HTTPBasicAuth
import re
from StringIO import StringIO
import uuid
# Inspired from https://gist.github.com/toudi/67d775066334dc024c24
# Tested on Jira 7.4 and Gitlab 2.2 with Python 2.7
JIRA_URL = 'https://your-jira-url.tld/'
JIRA_ACCOUNT = ('jira-username', 'jira-password')
# the JIRA project ID (short)
JIRA_PROJECT = 'PRO'
# Jira Query
#JQL = 'key=PRO-1182'
JQL = 'project=%s+AND+(resolution=Unresolved+OR+Sprint+in+openSprints())+ORDER+BY+createdDate+ASC&maxResults=10000' % JIRA_PROJECT
GITLAB_URL = 'http://your-gitlab-url.tld/'
# this is needed for importing attachments. The script will login to gitlab under the hood.
GITLAB_ACCOUNT = ('gitlab-username', 'gitlab-password')
# this token will be used whenever the API is invoked and
# the script will be unable to match the jira's author of the comment / attachment / issue
# this identity will be used instead.
GITLAB_TOKEN = 'get-this-token-from-your-profile'
# the project in gitlab that you are importing issues to.
GITLAB_PROJECT = 'namespaced/project/name'
# the numeric project ID. If you don't know it, the script will search for it
# based on the project name.
GITLAB_PROJECT_ID = None
# set this to false if JIRA / Gitlab is using self-signed certificate.
VERIFY_SSL_CERTIFICATE = True
# Add a comment with the link to the Jira issue
ADD_A_LINK = True
# the Jira Epic custom field
JIRA_EPIC_FIELD = 'customfield_10540'
# the Jira Sprints custom field
JIRA_SPRINT_FIELD = 'customfield_10340'
# the Jira story points custom field
JIRA_STORY_POINTS_FIELD = 'customfield_10002'
# IMPORTANT !!!
# make sure that user (in gitlab) has access to the project you are trying to
# import into. Otherwise the API request will fail.
# jira user name as key, gitlab as value
# if you want dates and times to be correct, make sure every user is (temporarily) admin
GITLAB_USER_NAMES = {
'jira': 'gitlab',
}
# Convert Jira issue types to Gitlab labels
# Warning: If a Jira issue type isn't in the map, the issue will be skipped!
ISSUE_TYPES_MAP = {
'Bug': 'bug',
'Improvement': 'enhancement',
'Spike': 'spike',
'Story': 'story',
'Task': 'task'
}
# (Enterprise Edition) Convert Jira story points to Gitlab issue weight
STORY_POINTS_MAP = {
1.0: 1,
2.0: 2,
3.0: 3,
5.0: 4,
8.0: 5,
13.0: 6,
20.0: 7,
40.0: 8,
100.0: 9
}
# TODO: Do all replacements once
# Gitlab markdown : https://docs.gitlab.com/ee/user/markdown.html
# Jira text formatting notation : https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all
def multiple_replace(text, adict):
if text is None:
return ''
t = text
t = re.sub(r'(\r\n){1}', r' \1', t) # line breaks
t = re.sub(r'\{code:([a-z]+)\}\s*', r'\n```\1\n', t) # Block code
t = re.sub(r'\{code\}\s*', r'\n```\n', t) # Block code
t = re.sub(r'\n\s*bq\. (.*)\n', r'\n\> \1\n', t) # Block quote
t = re.sub(r'\{quote\}', r'\n\>\>\>\n', t) # Block quote #2
t = re.sub(r'\{color:[\#\w]+\}(.*)\{color\}', r'> **\1**', t) # Colors
t = re.sub(r'\n-{4,}\n', r'---', t) # Ruler
t = re.sub(r'\[~([a-z]+)\]', r'@\1', t) # Links to users
t = re.sub(r'\[([^|\]]*)\]', r'\1', t) # Links without alt
t = re.sub(r'\[(?:(.+)\|)([a-z]+://.+)\]', r'[\1](\2)', t) # Links with alt
t = re.sub(r'(\b%s-\d+\b)' % JIRA_PROJECT, r'[\1](%sbrowse/\1)' % JIRA_URL, t) # Links to other issues
# Lists
t = re.sub(r'\n *\# ', r'\n 1. ', t) # Ordered list
t = re.sub(r'\n *[\*\-\#]\# ', r'\n 1. ', t) # Ordered sub-list
t = re.sub(r'\n *[\*\-\#]{2}\# ', r'\n 1. ', t) # Ordered sub-sub-list
t = re.sub(r'\n *\* ', r'\n - ', t) # Unordered list
t = re.sub(r'\n *[\*\-\#][\*\-] ', r'\n - ', t) # Unordered sub-list
t = re.sub(r'\n *[\*\-\#]{2}[\*\-] ', r'\n - ', t) # Unordered sub-sub-list
# Text effects
t = re.sub(r'(^|[\W])\*(\S.*\S)\*([\W]|$)', r'\1**\2**\3', t) # Bold
t = re.sub(r'(^|[\W])_(\S.*\S)_([\W]|$)', r'\1*\2*\3', t) # Emphasis
t = re.sub(r'(^|[\W])-(\S.*\S)-([\W]|$)', r'\1~~\2~~\3', t) # Deleted / Strikethrough
t = re.sub(r'(^|[\W])\+(\S.*\S)\+([\W]|$)', r'\1__\2__\3', t) # Underline
t = re.sub(r'(^|[\W])\{\{(.*)\}\}([\W]|$)', r'\1`\2`\3', t) # Inline code
# Titles
t = re.sub(r'\n?\bh1\. ', r'\n# ', t)
t = re.sub(r'\n?\bh2\. ', r'\n## ', t)
t = re.sub(r'\n?\bh3\. ', r'\n### ', t)
t = re.sub(r'\n?\bh4\. ', r'\n#### ', t)
t = re.sub(r'\n?\bh5\. ', r'\n##### ', t)
t = re.sub(r'\n?\bh6\. ', r'\n###### ', t)
# Emojis : https://emoji.codes
t = re.sub(r':\)', r':smiley:', t)
t = re.sub(r':\(', r':disappointed:', t)
t = re.sub(r':P', r':yum:', t)
t = re.sub(r':D', r':grin:', t)
t = re.sub(r';\)', r':wink:', t)
t = re.sub(r'\(y\)', r':thumbsup:', t)
t = re.sub(r'\(n\)', r':thumbsdown:', t)
t = re.sub(r'\(i\)', r':information_source:', t)
t = re.sub(r'\(/\)', r':white_check_mark:', t)
t = re.sub(r'\(x\)', r':x:', t)
t = re.sub(r'\(!\)', r':warning:', t)
t = re.sub(r'\(\+\)', r':heavy_plus_sign:', t)
t = re.sub(r'\(-\)', r':heavy_minus_sign:', t)
t = re.sub(r'\(\?\)', r':grey_question:', t)
t = re.sub(r'\(on\)', r':bulb:', t)
#t = re.sub(r'\(off\)', r'::', t) # Not found
t = re.sub(r'\(\*[rgby]?\)', r':star:', t)
for k, v in adict.iteritems():
t = re.sub(k, v, t)
return t
# We use UUID in place of the filename to prevent 500 errors on unicode chars
def move_attachements(attachments):
replacements = {}
if len(attachments):
for attachment in attachments:
author = attachment['author']['name']
_file = requests.get(
attachment['content'],
auth=HTTPBasicAuth(*JIRA_ACCOUNT),
verify=VERIFY_SSL_CERTIFICATE,
)
_content = StringIO(_file.content)
file_info = requests.post(
GITLAB_URL + 'api/v4/projects/%s/uploads' % GITLAB_PROJECT_ID,
headers={'PRIVATE-TOKEN': GITLAB_TOKEN,'SUDO': resolve_login(author)},
files={
'file': (
str(uuid.uuid4()),
_content
)
},
verify=VERIFY_SSL_CERTIFICATE
).json()
del _content
# now we got the upload URL. Let's post the comment with an
# attachment
if file_info.has_key('url'):
key = "!%s[^!]*!" % attachment['filename']
value = "![%s](%s)" % (attachment['filename'], file_info['url'])
replacements[key] = value
return replacements
def get_milestone_id(string):
for milestone in gl_milestones:
if milestone['title'] == string:
return milestone['id']
# Milestone doesn't yet exist, so we create it
milestone = requests.post(
GITLAB_URL + 'api/v4/projects/%s/milestones' % GITLAB_PROJECT_ID,
headers={'PRIVATE-TOKEN': GITLAB_TOKEN},
verify=VERIFY_SSL_CERTIFICATE,
data={
'title': string
}
).json()
gl_milestones.append(milestone)
return milestone['id']
# Get the user name from the GITLAB_USER_NAMES dict
# Or if logins match between Jira and Gitlab, use it
# In other cases (eg. inactive Jira user not created in Gitlab) we use GITLAB_ACCOUNT
def resolve_login(jira_user):
if GITLAB_USER_NAMES.has_key(jira_user):
return GITLAB_USER_NAMES[jira_user]
for user in gl_users:
if user['username'] == jira_user:
return user['username']
return GITLAB_ACCOUNT[0]
if not GITLAB_PROJECT_ID:
# find out the ID of the project.
for project in requests.get(
GITLAB_URL + 'api/v4/projects',
headers={'PRIVATE-TOKEN': GITLAB_TOKEN},
verify=VERIFY_SSL_CERTIFICATE
).json():
if project['path_with_namespace'] == GITLAB_PROJECT:
GITLAB_PROJECT_ID = project['id']
break
if not GITLAB_PROJECT_ID:
raise Exception("Unable to find %s in gitlab!" % GITLAB_PROJECT)
gl_milestones = requests.get(
GITLAB_URL + 'api/v4/projects/%s/milestones' % GITLAB_PROJECT_ID,
headers={'PRIVATE-TOKEN': GITLAB_TOKEN},
verify=VERIFY_SSL_CERTIFICATE
).json()
gl_users = requests.get(
GITLAB_URL + 'api/v4/users',
headers={'PRIVATE-TOKEN': GITLAB_TOKEN},
verify=VERIFY_SSL_CERTIFICATE
).json()
# Jira API documentation : https://developer.atlassian.com/static/rest/jira/6.1.html
jira_issues = requests.get(
JIRA_URL + 'rest/api/2/search?jql=' + JQL,
auth=HTTPBasicAuth(*JIRA_ACCOUNT),
verify=VERIFY_SSL_CERTIFICATE,
headers={'Content-Type': 'application/json'}
).json()['issues']
for issue in jira_issues:
if issue['fields']['issuetype']['name'] not in ISSUE_TYPES_MAP:
continue
gl_assignee = ''
if issue['fields']['assignee']:
for user in gl_users:
if user['username'] == issue['fields']['assignee']['name']:
gl_assignee = user['id']
break
labels = [ISSUE_TYPES_MAP[issue['fields']['issuetype']['name']]]
if issue['fields']['status']['statusCategory']['name'] == "In Progress":
labels.append(issue['fields']['status']['name'])
# Add Epic name to labels
if issue['fields'][JIRA_EPIC_FIELD]:
epic_info = requests.get(
JIRA_URL + 'rest/api/2/issue/%s/?fields=summary' % issue['fields'][JIRA_EPIC_FIELD],
auth=HTTPBasicAuth(*JIRA_ACCOUNT),
verify=VERIFY_SSL_CERTIFICATE,
headers={'Content-Type': 'application/json'}
).json()
labels.append(epic_info['fields']['summary'])
# Use the name of the last sprint as milestone
milestone_id = None
if issue['fields'][JIRA_SPRINT_FIELD]:
for sprint in issue['fields'][JIRA_SPRINT_FIELD]:
m = re.search(r'name=([^,]+),', sprint)
if m:
name = m.group(1)
if name:
milestone_id = get_milestone_id(m.group(1))
# Gitlab expect the timezone in +00:00 format without milliseconds while Jira gives +0000 with milliseconds
reporter = issue['fields']['reporter']['name']
# get comments and attachments from Jira
issue_info = requests.get(
JIRA_URL + 'rest/api/2/issue/%s/?fields=attachment,comment' % issue['id'],
auth=HTTPBasicAuth(*JIRA_ACCOUNT),
verify=VERIFY_SSL_CERTIFICATE,
headers={'Content-Type': 'application/json'}
).json()
replacements = move_attachements(issue_info['fields']['attachment'])
data = {
'assignee_ids': [gl_assignee],
'title': issue['fields']['summary'],
'description': multiple_replace(issue['fields']['description'], replacements),
'milestone_id': milestone_id,
'labels': ", ".join(labels),
}
# Issue weight
if JIRA_STORY_POINTS_FIELD in issue['fields'] and issue['fields'][JIRA_STORY_POINTS_FIELD]:
data['weight'] = STORY_POINTS_MAP[issue['fields'][JIRA_STORY_POINTS_FIELD]]
gl_issue = requests.post(
GITLAB_URL + 'api/v4/projects/%s/issues' % GITLAB_PROJECT_ID,
headers={'PRIVATE-TOKEN': GITLAB_TOKEN,'SUDO': resolve_login(reporter)},
verify=VERIFY_SSL_CERTIFICATE,
data=data
).json()
#Add a comment with the link to the Jira issue
if ADD_A_LINK:
body = "Imported from Jira issue [%(k)s](%(u)sbrowse/%(k)s)" % {'k': issue['key'], 'u': JIRA_URL}
note_add = requests.post(
GITLAB_URL + 'api/v4/projects/%s/issues/%s/notes' % (GITLAB_PROJECT_ID, gl_issue['iid']),
headers={'PRIVATE-TOKEN': GITLAB_TOKEN},
verify=VERIFY_SSL_CERTIFICATE,
data={'body': body}
)
for comment in issue_info['fields']['comment']['comments']:
author = comment['author']['name']
note_add = requests.post(
GITLAB_URL + 'api/v4/projects/%s/issues/%s/notes' % (GITLAB_PROJECT_ID, gl_issue['iid']),
headers={'PRIVATE-TOKEN': GITLAB_TOKEN,'SUDO': resolve_login(author)},
verify=VERIFY_SSL_CERTIFICATE,
data={'body': multiple_replace(comment['body'], replacements)}
)
if issue['fields']['status']['statusCategory']['key'] == "done":
requests.put(
GITLAB_URL + 'api/v4/projects/%s/issues/%s' % (GITLAB_PROJECT_ID, gl_issue['iid']),
headers={'PRIVATE-TOKEN': GITLAB_TOKEN},
verify=VERIFY_SSL_CERTIFICATE,
data={'state_event': 'close'}
)
@Podolyan98
Copy link

It works?

I create a issue on GitLab like this(Python 3):

user_id = int(11)

gl_issue = requests.post(
    GITLAB_URL + '/api/v4/projects/%s/issues' % GITLAB_PROJECT_ID,
    headers={'PRIVATE-TOKEN': GITLAB_TOKEN,'SUDO': GITLAB_USER_NAMES.get(reporter, reporter)},
    verify=VERIFY_SSL_CERTIFICATE,
    data={
        'title': issue['fields']['summary'],
        'description': issue['fields']['description'],
        'created_at': issue['fields']['created'],
        'assignee_ids' : [user_id],
    })

Issue created but the assignee is not set. What am I doing wrong?

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