From b4cf4d83505d7f09e85aec3aa28c8ab9beade71e Mon Sep 17 00:00:00 2001 From: Leszek Godlewski Date: Fri, 1 Jul 2016 12:11:39 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 3 + README.md | 49 ++++++++ trello2redmine.py | 239 +++++++++++++++++++++++++++++++++++++++ trello2redmine_config.py | 46 ++++++++ 4 files changed, 337 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 trello2redmine.py create mode 100644 trello2redmine_config.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7f707e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.json +*.pyc +*.txt \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..404d737 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# trello2redmine + +This is a tiny tool I've written to migrate all of our issue tracking data at once. It uses Trello's JSON export feature and the JSON flavour of [Redmine's REST API](http://www.redmine.org/projects/redmine/wiki/Rest_api). + +We had been using [Trello](http://trello.com) as a stop-gap solution while our internal services (including [Redmine](http://www.redmine.org)) were being set up. Hence the limited scope of support for features – it was a one-time operation, and I don't really have a vested interest in the application, but it may be of use to others. + +## Dependencies + +Tested with Python 2.7, but was written with Python 3 compatibility in mind. + +Beyond the standard library, the script only requires [Requests](http://docs.python-requests.org/en/master/user/install/) for HTTP support. + +## Configuration and usage + +Edit [trello2redmine_config.py](trello2redmine_config.py) and fill in the details of your installation. Then just run the script. + +## Known issues + +Not all features of Trello are supported. What works: + +* creating Redmine issues from Trello cards, +* importing Trello card comments as Redmine issue comments, +* importing Trello checklists as plain text, appended to the issue description, +* mapping Trello users to Redmine users, +* mapping Trello lists to Redmine statuses, +* mapping Trello card labels to Redmine priorities. + +What doesn't: + +* multiple Trello card assignees: corresponding Redmine issue will only be assigned to the first one in Redmine, +* everything else. + +## Licensing + +``` + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2016 Leszek Godlewski + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. +``` \ No newline at end of file diff --git a/trello2redmine.py b/trello2redmine.py new file mode 100644 index 0000000..bf27de9 --- /dev/null +++ b/trello2redmine.py @@ -0,0 +1,239 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +import trello2redmine_config as cfg + +import sys +import json +import requests +import urlparse + +if len(sys.argv) > 3 or (len(sys.argv) == 2 and sys.argv[1] == '-h'): + print('Usage: {0} [])'.format(sys.argv[0])) + print('See %s for configuration.' % cfg.__file__) + print('Options:') + print(' -h Displays this information.') + print(' -c Commits the import. Otherwise does a dry run (prints resulting JSON to screen instead of sending it to Redmine).') + sys.exit(0) + +# If a dry run, JSON is printed instead of submitted to Redmine. +dry_run = len(sys.argv) < 3 or sys.argv[2] != '-c' +if dry_run: + print('Making a dry run! Re-run with -c to commit the import into Redmine.') +else: + print('Making a commit!') + +url = urlparse.urlparse(cfg.trello_board_link) +if not url.netloc.endswith('trello.com'): + print('URL does not seem to be a Trello board link. Are you sure this is correct?') + sys.exit(1) + +# ============================================================================ +# Trello JSON processing starts here. +# ============================================================================ + +print('Downloading board JSON from Trello...') +sys.stdout.flush() + +#board = json.loads(open(sys.argv[1]).read()) +board = requests.get(cfg.trello_board_link + '.json').json() + +print('Processing board JSON...') +sys.stdout.flush() + +orig_lists_dict = {} +lists_dict = {} +for l in board["lists"]: + name = l["name"] + if name in cfg.list_map: + name = cfg.list_map[name] + lists_dict[l["id"]] = name + orig_lists_dict[l["id"]] = l["name"] + +checklists_dict = {} +for c in board["checklists"]: + checklists_dict[c["id"]] = c["checkItems"] + +members_dict = {} +for m in board["members"]: + name = m["fullName"] + if name in cfg.username_map: + name = cfg.username_map[name] + members_dict[m["id"]] = name + +labels_dict = {} +for l in board["labels"]: + label = l["name"] + if label in cfg.label_map: + label = cfg.label_map[label] + labels_dict[l["id"]] = label + +comments_dict = {} +for a in board["actions"]: + if a["type"] == "commentCard": + card_id = a["data"]["card"]["id"] + author_id = a["idMemberCreator"] + comment_list = comments_dict[card_id] if card_id in comments_dict else [] + comment_list.append({ + "author": members_dict[author_id] if author_id in members_dict else '', + "text": a["data"]["text"], + }) + comments_dict[card_id] = comment_list + +#print('Members:\n' + str(members_dict)) +#print('Lists:\n' + str(lists_dict)) +#print('Checklists:\n' + str(checklists_dict)) +#print('Comments:\n' + str(comments_dict)) + +# ============================================================================ +# Redmine configuration processing starts here. +# ============================================================================ + +print('Querying Redmine configuration...') +sys.stdout.flush() + +redmine_projects = requests.get(cfg.redmine_root_url + '/projects.json', verify=cfg.redmine_verify_certificates, headers={'X-Redmine-API-Key': cfg.redmine_api_key}).json() +redmine_project_id = -1 +for rp in redmine_projects["projects"]: + if rp["identifier"] == cfg.redmine_project_identifier: + redmine_project_id = rp["id"] + break + +if redmine_project_id < 0: + print('Project with identifier {0} does not exist in Redmine!\n\n{1}'.format(cfg.redmine_project_identifier, str(redmine_users_dict))) + sys.exit(1) +#print('Redmine project ID: {0}'.format(redmine_project_id)) + +redmine_users = requests.get(cfg.redmine_root_url + '/users.json', verify=cfg.redmine_verify_certificates, headers={'X-Redmine-API-Key': cfg.redmine_api_key}).json() +redmine_users_dict = {} +for ru in redmine_users["users"]: + fullname = u'{0} {1}'.format(ru["firstname"], ru["lastname"]) + redmine_users_dict[fullname] = ru["id"] + +#print('Redmine users:\n' + str(redmine_users_dict)) + +if not cfg.redmine_default_user in redmine_users_dict: + print('Default user does not exist in Redmine!\n\n{0}'.format(str(redmine_users_dict))) + sys.exit(1) + +redmine_priorities = requests.get(cfg.redmine_root_url + '/enumerations/issue_priorities.json', verify=cfg.redmine_verify_certificates, headers={'X-Redmine-API-Key': cfg.redmine_api_key}).json() +redmine_priorities_dict = {} +redmine_default_priority = "Normalny" +for rp in redmine_priorities["issue_priorities"]: + redmine_priorities_dict[rp["name"]] = rp["id"] + if "is_default" in rp and rp["is_default"]: + redmine_default_priority = rp["name"] + +#print(u'Redmine priorities:\n' + str(redmine_priorities_dict)) + +redmine_statuses = requests.get(cfg.redmine_root_url + '/issue_statuses.json', verify=cfg.redmine_verify_certificates, headers={'X-Redmine-API-Key': cfg.redmine_api_key}).json() +redmine_statuses_dict = {} +redmine_default_status = "Nowy" +for rs in redmine_statuses["issue_statuses"]: + redmine_statuses_dict[rs["name"]] = rs["id"] + if "is_default" in rs and rs["is_default"]: + redmine_default_status = rs["name"] + +#print(u'Redmine statuses:\n' + str(redmine_priorities_dict)) + +# ============================================================================ +# Direct Trello-to-Redmine mappings are made here. +# ============================================================================ + +print('Generating configuration mappings...') +sys.stdout.flush() + +user_map = {} +for id, fullname in members_dict.iteritems(): + if not fullname in redmine_users_dict: + print(u'Warning: user {0} not found in Redmine, defaulting to {1}'.format(fullname, cfg.redmine_default_user)) + fullname = cfg.redmine_default_user + redmine_id = redmine_users_dict[fullname] + user_map[id] = redmine_id + #print(u'Matched user {0}, Trello ID {1}, Redmine ID {2}'.format(fullname, id, redmine_id).encode('utf-8')) + +#print(u'User ID map:\n{0}\nDefault user ID: {1}'.format(str(user_map), redmine_users_dict[cfg.redmine_default_user]).encode('utf-8')) + +priority_map = {} +for id, name in labels_dict.iteritems(): + if not name in redmine_priorities_dict: + print(u'Warning: Trello label {0} is not mapped to a Redmine priority, defaulting to {1}'.format(name, redmine_default_priority).encode('utf-8')) + name = redmine_default_priority + redmine_id = redmine_priorities_dict[name] + priority_map[id] = redmine_id + #print(u'Matched label {0}, Trello ID {1}, Redmine ID {2}'.format(name, id, redmine_id).encode('utf-8')) + +#print('Redmine priorities:\n' + str(redmine_priorities_dict)) + +status_map = {} +for id, name in lists_dict.iteritems(): + if not name in redmine_statuses_dict: + print(u'Warning: Trello list {0} is not mapped to a Redmine status, defaulting to {1}'.format(name, redmine_default_status).encode('utf-8')) + name = redmine_default_status + redmine_id = redmine_statuses_dict[name] + status_map[id] = redmine_id + #print(u'Matched list {0}, Trello ID {1}, Redmine ID {2}'.format(name, id, redmine_id).encode('utf-8')) + +#print('Redmine statuses:\n' + str(redmine_statuses_dict)) + +# ============================================================================ +# Finally, cards processing. +# ============================================================================ + +print ('Processing cards...') +sys.stdout.flush() + +dry_run_counter = 0 +for c in board["cards"]: + desc = c["desc"] + if c["idChecklists"]: + desc += u'\n' + for id in c["idChecklists"]: + for item in checklists_dict[id]: + desc += u'\n[{0}] {1}'.format(u'x' if item["state"] == u'complete' else u' ', item["name"]) + card = { + "issue": { + "subject": u'[{0}][{1}] {2}'.format(board["name"], orig_lists_dict[c["idList"]], c["name"]), + "description": desc, + "project_id": redmine_project_id, + "assigned_to_id": user_map[c["idMembers"][0]] if c["idMembers"] else redmine_users_dict[cfg.redmine_default_user], + "status_id": status_map[c["idList"]] if c["idList"] else redmine_statuses_dict[redmine_default_status], + "priority_id": priority_map[c["idLabels"][0]] if c["idLabels"] else redmine_priorities_dict[redmine_default_priority], + "is_private": False + } + } + print(u'Importing {0}...'.format(card["issue"]["subject"]).encode('utf-8')) + issue_id = -1 + if dry_run: + dry_run_counter += 1 + issue_id = dry_run_counter + print(json.dumps(card, sort_keys=False, indent=4, separators=(',', ': '))) + else: + result = requests.post(cfg.redmine_root_url + '/issues.json', data=json.dumps(card), verify=cfg.redmine_verify_certificates, headers={'X-Redmine-API-Key': cfg.redmine_api_key, "Content-Type": "application/json"}) + if result.status_code >= 400: + print('Error {0}: Request headers:\n{1}\nResponse headers:\n{2}\nContent: {3}'.format(str(result), result.request.headers, result.headers, result.content)) + sys.exit(1) + else: + print(str(result)) + issue = result.json() + issue_id = issue["issue"]["id"] + #print(u'Response JSON:\n{0}\nIssue ID: {1}'.format(str(issue), issue_id).encode('utf-8')) + if issue_id >= 0 and c["id"] in comments_dict: + for index, comment in enumerate(reversed(comments_dict[c["id"]])): + update = { + "issue": { + "notes": u'{0}:\n{1}'.format(comment["author"], comment["text"]).format('utf-8') if len(comment["author"]) > 0 else comment["text"] + } + } + print(u'Importing comment {0} of {1}...'.format(index + 1, len(comments_dict[c["id"]])).encode('utf-8')) + if dry_run: + print(json.dumps(update, sort_keys=False, indent=4, separators=(',', ': '))) + else: + result = requests.put(cfg.redmine_root_url + '/issues/{0}.json'.format(issue_id), data=json.dumps(update), verify=cfg.redmine_verify_certificates, headers={'X-Redmine-API-Key': cfg.redmine_api_key, "Content-Type": "application/json"}) + if result.status_code >= 400: + print('Error {0}: Request headers:\n{1}\nResponse headers:\n{2}\nContent: {3}'.format(str(result), result.request.headers, result.headers, result.content)) + sys.exit(1) + else: + print(str(result)) + +print('Done!') \ No newline at end of file diff --git a/trello2redmine_config.py b/trello2redmine_config.py new file mode 100644 index 0000000..36bba65 --- /dev/null +++ b/trello2redmine_config.py @@ -0,0 +1,46 @@ +# This is the trello2redmine configuration file. +# All data here is exemplary, you need to fill it properly for the script to work! + +# Trello configuration +trello_board_link = 'https://example.com' # As found on the board in Menu -> More -> Link to this board. + +# Redmine configuration +redmine_root_url = 'https://example.com' +redmine_project_identifier = 'ProjectID' # As appears in the Redmine URLs. +redmine_default_user = 'Foo Bar' # Trello cards which are unassigned or whose assignee does not have a mapping in username_map will be assigned to this Redmine user. +redmine_verify_certificates = False # Whether to verify SSL certificates. +redmine_api_key = 'fake key' # Redmine REST API key. See: http://www.redmine.org/projects/redmine/wiki/Rest_api#Authentication + +# Trello card label to Redmine priority map. +label_map = { + u'': u'Normalny', # Default for no label. + u'Trivial': u'Trywialny', + u'Mało ważne': u'Niski', + u'Normalne': u'Normalny', + u'Ważne': u'Wysoki', + u'VSD': u'Pilny', + u'Krytyczne': u'Natychmiastowy', +} + +# Trello-to-Redmine username map. Both are displayed names, *not* logins or IDs! Redmine names are resolved to user IDs behind the scenes. +username_map = { + u'Trello User': u'Redmine User', + u'Foo Bar': u'Foo Bar', + u'Nickname': u'Full Name', +} + +# Trello list to Redmine status map. Redmine statuses are displayed names, *not* IDs! Statuses are resolved to IDs behind the scenes. +list_map = { + u'TODO': u'Nowy', + u'To Do': u'Nowy', + u'ToDo': u'Nowy', + u'Backlog': u'Nowy', + u'Doing': u'W toku', + u'In progress': u'W toku', + u'In Progress': u'W toku', + u'WIP': u'W toku', + u'Done': u'Rozwiązany', + u'Confirmed': u'Zamknięty', + u"Won't fix/No repro": u'Odrzucony', + u"Won't done/fix": u'Odrzucony', +} \ No newline at end of file -- 2.43.0