~petersanchez/trello2redmine

b4cf4d83505d7f09e85aec3aa28c8ab9beade71e — Leszek Godlewski 7 years ago
Initial commit.
4 files changed, 337 insertions(+), 0 deletions(-)

A .gitignore
A README.md
A trello2redmine.py
A trello2redmine_config.py
A  => .gitignore +3 -0
@@ 1,3 @@
*.json
*.pyc
*.txt
\ No newline at end of file

A  => README.md +49 -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

A  => trello2redmine.py +239 -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} [<options>])'.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

A  => trello2redmine_config.py +46 -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