I am somewhat unlucky. Being Python and Perl devotee, I just had to
install PHP-based blog (all Python blogs seems to be pre-alpha,
Perlish Movable Type is solid, but unclearly licensed and rumoured to be
difficult to maintain). Now I wanted some reasonably looking issue-tracker,
and it turned out that Trac is as far from multi-project handling, as it
was two years ago, so I picked Redmine. Ruby on Rails application.
Yes, Redmine is what many people wanted Trac to be. Issues, wiki,
timelines, roadmaps - but also file distribution, forums, categorization,
and more. And very good, natural, support for multiple projects (including
cross-project reports and moving issues between projects).
I had to write small custom issue importer. Maybe it will be of some use
for somebody as a basis for another custom importer.... And yes, I wrote
it in Python.
My context
I had some issues saved in Be. This is a tiny utility
which saves issues as files within Bazaar/Mercurial repository.
Nothing spectacular:
$ be new "My app crashes on utf-8 file"
Created bug with ID c48
$ be comment c48
*editor session here*
$ be list
*plain textual list*
$ be show c48
*plain textual report*
(and all issues are saved in .be subdirectory)
Fairly handy while adding issues, but unmanageable when there are
more than 10 open issues.
Custom import
So here is my custom Be to Redmine importer. The script is
based on SqlAlchemy and illustrates how sweet this library is.
In a very similar way one should be able to import issues from
something else.
This is not a full-blown importer. It assigns all issues to the same
person, and assumes the destination project already exists. It can be
easily extended to handle multi-user case... Also, I finally decided
to save all comments as single initial description (alternatively I
could play with journals table and generate history).
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Redmine database URL
DB = "postgres://USERNAME:PASSWORD@localhost:5432/DATABASE"
# Source (directory with .be subdir)
SOURCE_DIR = '/home/marcin/mercurial_repos/watchbot'
# Destination Redmine project (a name)
BUG_PROJECT = 'WatchBot'
# Owner of all imported issues (login name)
BUG_OWNER = 'marcin'
# Redmine tracker type (Task, Error, ...)
BUG_TRACKER = 'Task'
# Status given to non-resolved issues
BUG_NEW_STATUS = 'New'
# Status given to fixed issues
BUG_FIXED_STATUS = u'Fixed'
############################################################
from sqlalchemy import create_engine, MetaData
from sqlalchemy import Column, Table, \
DateTime, UnicodeText, Integer, String, Unicode
from sqlalchemy.sql import func
from sqlalchemy.orm import mapper, sessionmaker
############################################################
# Reflect the Redmine database
############################################################
engine = create_engine(DB, echo=True)
metadata = MetaData()
# Load the tables definitions. I force some columns
# to be Unicode instead of String to avoid problems
# with my Polish characters ("Can't decode...")
issues_table = Table("issues", metadata,
Column('subject', Unicode()),
Column('description', Unicode()),
autoload = True, autoload_with = engine)
comments_table = Table("comments", metadata,
Column('comments', Unicode()),
autoload = True, autoload_with = engine)
journals_table = Table("journals", metadata,
Column('notes', Unicode()),
autoload = True, autoload_with = engine)
projects_table = Table("projects", metadata,
autoload = True, autoload_with = engine)
users_table = Table("users", metadata,
autoload = True, autoload_with = engine)
trackers_table = Table("trackers", metadata,
autoload = True, autoload_with = engine)
issue_statuses_table = Table("issue_statuses", metadata,
autoload = True, autoload_with = engine)
# ORM mapper classes and mappings
class Issue(object):
pass
class Comment(object):
pass
class Journal(object):
pass
class Project(object):
pass
class User(object):
pass
class Tracker(object):
pass
class IssueStatus(object):
pass
mapper(Issue, issues_table)
mapper(Comment, comments_table)
mapper(Journal, journals_table)
mapper(Project, projects_table)
mapper(User, users_table)
mapper(Tracker, trackers_table)
mapper(IssueStatus, issue_statuses_table)
############################################################
# Connect to the database
Session = sessionmaker(autoflush = True, transactional = True)
Session.configure(bind = engine)
session = Session()
############################################################
# Redmine-specific part (creating issues)
############################################################
# Find numeric identifiers for the configuration variables
# specified on the very beginning.
my_user = session.query(User).filter(User.login == BUG_OWNER).one()
my_project = session.query(Project).filter(Project.name == BUG_PROJECT).one()
my_tracker = session.query(Tracker).filter(Tracker.name == BUG_TRACKER).one()
my_open_status = session.query(IssueStatus).filter(IssueStatus.name == BUG_NEW_STATUS).one()
my_fixed_status = session.query(IssueStatus).filter(IssueStatus.name == BUG_FIXED_STATUS).one()
#print "%s %s %s" % (my_user.id, my_user.login, my_user.firstname)
#print "%s %s %s" % (my_project.id, my_project.name, my_project.description)
#print "%s %s" % (my_tracker.id, my_tracker.name)
#print "%s %s" % (my_status.id, my_status.name)
def create_issue(subject, description, created_on, is_fixed):
issue = Issue()
issue.subject = subject
issue.description = description
issue.assigned_to_id = my_user.id
issue.tracker_id = my_tracker.id
issue.project_id = my_project.id
if is_fixed:
issue.status_id = my_fixed_status.id
else:
issue.status_id = my_open_status.id
issue.priority_id = 1 ### Too lazy here
issue.author_id = my_user.id
issue.lock_version = 0 ### Lazy too
issue.done_ratio = 0
#issue.category_id = ...let's categorize after import...
issue.created_on = created_on
issue.updated_on = datetime.datetime.now()
session.save(issue)
############################################################
# Be specific part (reading issues)
############################################################
# Note: I manually read Be database structures. I initially
# tried popen-ing `be list` and such, but faced some be
# bug due to which it crashed on non-ascii characters
# when piped or even redirected to a file.
import dircache
import os.path
import codecs
import re
import datetime
def be_issues():
"""
Generator, yields issue_identifiers
"""
be_dir = os.path.join(SOURCE_DIR, '.be', 'bugs')
data = dircache.listdir(be_dir)
for d in data:
yield os.path.join(be_dir, d)
re_val = re.compile("^(\w+)=(.*)$")
def be_parse_values_file(filename):
f = codecs.open(filename, "r", "utf-8")
d = {}
for l in f:
m = re_val.match(l)
if m:
name = m.group(1).lower()
value = m.group(2)
if name == 'time' or name == 'date':
value = datetime.datetime.strptime(value, "%a, %d %b %Y %H:%M:%S +0000")
d[ name ] = value
return d
def be_issue_detail(issue_id):
"""
Returns issue details - dictionary with 'creator', 'severity', 'status',
'summary' and 'time' fields
"""
return be_parse_values_file(os.path.join(issue_id, 'values'))
def be_issue_comments(issue_id):
"""
Yields all comments on issue. Each comment is dict 'date' and 'body'.
Comments are sorted by date.
"""
comments = []
comment_dir = os.path.join(issue_id, 'comments')
if not os.path.isdir(comment_dir):
return []
for cdir in dircache.listdir(comment_dir):
here_dir = os.path.join(comment_dir, cdir)
body = "".join(codecs.open(os.path.join(here_dir, "body"), "r", "utf-8").readlines())
val = be_parse_values_file(os.path.join(here_dir, "values"))
comments.append(dict(date = val['date'], body = body, id = cdir))
comments.sort(key = lambda x: (x['date'], x['id']))
return comments
# Here is part of initial popen attempt:
#
# from subprocess import Popen, PIPE
#
# os.chdir(SOURCE_DIR)
# data = Popen(
# ["be", "list"],
# stdout = PIPE,
# env = os.environ,
# ).communicate()[0]
# for x in data.split("\n"):
# # ....
############################################################
# Main loop
############################################################
for issue_id in be_issues():
print "--- %s ---" % issue_id
detail = be_issue_detail(issue_id)
comments = be_issue_comments(issue_id)
if comments:
desc = "\n\n".join([ c['body'] for c in comments ])
else:
desc = '.'
create_issue(subject = detail['summary'],
description = desc,
created_on = detail['time'],
is_fixed = (detail['status'] == 'closed'))
session.flush()
#session.rollback()
session.commit()
Redmine notes
Short notes about Redmine installation:
It does not make sense to fight with standard Ruby (whether system
packaged, or manually installed), I lost two hours trying to get
all the components and failed. Instead, Ruby Enterprise Edition
installs in a few seconds, contains everything Redmine needs,
and together with Passenger is claimed to be most efficient
way to run Ruby on Rails application. So I recommend installing
the two (Ruby Enterprise Edition and Passenger) and only
then proceeding to Redmine installation.
I had problems with stable Redmine version (again, Ruby
version issues). The current trunk installed and works flawlessly.
So until Redmine guys publish Rails 2.1 - compatible version,
it is probably the best method to go.
The next version of Redmine should be compatible with Rails 2.1, since it will be based on the trunk version. I've been running the trunk version with very few errors over the past year so don't feel obligated to move to a released version unless you need to.