Last active
November 22, 2023 12:51
-
-
Save Gwerlas/980141404bccfa0b0c1d49f580c2d494 to your computer and use it in GitHub Desktop.
Migrate Jira issues to Gitlab
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'} | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
It works?
I create a issue on GitLab like this(Python 3):
Issue created but the assignee is not set. What am I doing wrong?