5

Ive just moved my projects code from java.net to BitBucket. But my jira issue tracking is still hosted on java.net, although BitBucket does have some options for linking to an external issue tracker I don't think I can use it for java.net, not least because I do not have the admin priviledges need to install the DVCS connector.

So I thought an alternative option would be to export and then import the issues into BitBucket issue tracker, is that possible ?

Progress so far So I tried following the steps in both informative answers using OSX below but I hit a problem - I'm rather confused about what the script would actually be called because in the answers it talks about export.py but no such script exists with that name so I renamed the one I downloaded.

  • sudo easy_install pip (OSX)
  • pip install jira
  • pip install configparser
  • easy_install -U setuptools
  • Go to https://bitbucket.org/reece/rcore, select downloads tab, download zip and unzip, and rename to reece ( for some reason git clone https://bitbucket.org/reece/rcore fails with error)
  • cd reece/rcore
  • Save script as export.py in rcore subfolder
  • Replace iteritems with items in import.py
  • Replace iteritems with types/immutabledict.py
  • Create .config in rcore folder

  • Create .config/jira-issues-move-to-bitbucket.conf containing

    jira-username=paultaylor

    jira-hostname=https://java.net/jira/browse/JAUDIOTAGGER

    jira-password=password

  • Run python export.py --jira-project jaudiotagger

gives

macbook:rcore paul$ python export.py --jira-project jaudiotagger
Traceback (most recent call last):
  File "export.py", line 24, in <module>
    import configparser
ImportError: No module named configparser
- Run python export.py --jira-project jaudiotagger

I need to run pip insdtall as root so did

  • sudo pip install configparser

and that worked

but now

  • python export.py --jira.project jaudiotagger

gives

File "export.py" line 35, in <module?
  from jira.client import JIRA
ImportError: No module named jira.client
Paul Taylor
  • 13,411
  • 42
  • 184
  • 351
  • is `pip install configparser` fails or succeeds? Your error is saying that the `configparser` is missing. Try it again and see if you get a `Requirement already satisfied`. – Fabio Oct 07 '14 at 09:57
  • @Fabio Thanks it tried to reinstall and there was an error I must have missed that somehow, so i retried with 'sudo pip install configparser' and that worked. But now when i retry it fails complaining no module named jira.client – Paul Taylor Oct 23 '14 at 09:10
  • oh of course i need to do sudo pip install jira, but now it fails on NO module named rcore.types.immutabledict – Paul Taylor Oct 23 '14 at 09:16
  • follow the instruction number 7 in my answer. – Fabio Oct 23 '14 at 20:36

2 Answers2

3

You can import issues into BitBucket, they just need to be in the appropriate format. Fortunately, Reece Hart has already written a Python script to connect to a Jira instance and export the issues.

To get the script to run I had to install the Jira Python package as well as the latest version of rcore (if you use pip you get an incompatible previous version, so you have to get the source). I also had to replace all instances of iteritems with items in the script and in rcore/types/immutabledict.py to make it work with Python 3. You will also need to fill in the dictionaries (priority_map, person_map, etc) with the values your project uses. Finally, you need a config file to exist with the connection info (see comments at the top of the script).

The basic command line usage is export.py --jira-project <project>

Once you've got the data exported, see the instructions for importing issues to BitBucket

#!/usr/bin/env python

"""extract issues from JIRA and export to a bitbucket archive

See:
https://confluence.atlassian.com/pages/viewpage.action?pageId=330796872
https://confluence.atlassian.com/display/BITBUCKET/Mark+up+comments
https://bitbucket.org/tutorials/markdowndemo/overview

2014-04-12 08:26 Reece Hart <reecehart@gmail.com>


Requires a file ~/.config/jira-issues-move-to-bitbucket.conf
with content like
[default]
jira-username=some.user
jira-hostname=somewhere.jira.com
jira-password=ur$pass

"""

import argparse
import collections
import configparser
import glob
import itertools
import json
import logging
import os
import pprint
import re
import sys
import zipfile

from jira.client import JIRA

from rcore.types.immutabledict import ImmutableDict


priority_map = {
    'Critical (P1)': 'critical',
    'Major (P2)': 'major',
    'Minor (P3)': 'minor',
    'Nice (P4)': 'trivial',
    }
person_map = {
    'reece.hart': 'reece',
    # etc
    }
issuetype_map = {
    'Improvement': 'enhancement',
    'New Feature': 'enhancement',
    'Bug': 'bug',
    'Technical task': 'task',
    'Task': 'task',
    }
status_map = {
    'Closed': 'resolved',
    'Duplicate': 'duplicate',
    'In Progress': 'open',
    'Open': 'new',
    'Reopened': 'open',
    'Resolved': 'resolved',
    }



def parse_args(argv):
    def sep_and_flatten(l):
        # split comma-sep elements and flatten list
        # e.g., ['a','b','c,d'] -> set('a','b','c','d')
        return list( itertools.chain.from_iterable(e.split(',') for e in l) )

    cf = configparser.ConfigParser()
    cf.readfp(open(os.path.expanduser('~/.config/jira-issues-move-to-bitbucket.conf'),'r'))

    ap = argparse.ArgumentParser(
        description = __doc__
        )

    ap.add_argument(
        '--jira-hostname', '-H',
        default = cf.get('default','jira-hostname',fallback=None),
        help = 'host name of Jira instances (used for url like https://hostname/, e.g., "instancename.jira.com")',
        )
    ap.add_argument(
        '--jira-username', '-u',
        default = cf.get('default','jira-username',fallback=None),
        )
    ap.add_argument(
        '--jira-password', '-p',
        default = cf.get('default','jira-password',fallback=None),
        )
    ap.add_argument(
        '--jira-project', '-j',
        required = True,
        help = 'project key (e.g., JRA)',
        )
    ap.add_argument(
        '--jira-issues', '-i',
        action = 'append',
        default = [],
        help = 'issue id (e.g., JRA-9); multiple and comma-separated okay; default = all in project',
        )
    ap.add_argument(
        '--jira-issues-file', '-I',
        help = 'file containing issue ids (e.g., JRA-9)'
        )
    ap.add_argument(
        '--jira-components', '-c',
        action = 'append',
        default = [],
        help = 'components criterion; multiple and comma-separated okay; default = all in project',
        )
    ap.add_argument(
        '--existing', '-e',
        action = 'store_true',
        default = False,
        help = 'read existing archive (from export) and merge new issues'
        )

    opts = ap.parse_args(argv)

    opts.jira_components = sep_and_flatten(opts.jira_components)
    opts.jira_issues = sep_and_flatten(opts.jira_issues)

    return opts


def link(url,text=None):
    return "[{text}]({url})".format(url=url,text=url if text is None else text)

def reformat_to_markdown(desc):
    def _indent4(mo):
        i = "    "
        return i + mo.group(1).replace("\n",i)
    def _repl_mention(mo):
        return "@" + person_map[mo.group(1)]
    #desc = desc.replace("\r","")
    desc = re.sub("{noformat}(.+?){noformat}",_indent4,desc,flags=re.DOTALL+re.MULTILINE)
    desc = re.sub(opts.jira_project+r"-(\d+)",r"issue #\1",desc)
    desc = re.sub(r"\[~([^]]+)\]",_repl_mention,desc)
    return desc

def fetch_issues(opts,jcl):
    jql = [ 'project = ' + opts.jira_project ]
    if opts.jira_components:
        jql += [ ' OR '.join([ 'component = '+c for c in opts.jira_components ]) ]
    if opts.jira_issues:
        jql += [ ' OR '.join([ 'issue = '+i for i in opts.jira_issues ]) ]
    jql_str = ' AND '.join(["("+q+")" for q in jql])
    logging.info('executing query ' + jql_str)
    return jcl.search_issues(jql_str,maxResults=500)


def jira_issue_to_bb_issue(opts,jcl,ji):
    """convert a jira issue to a dictionary with values appropriate for
    POSTing as a bitbucket issue"""
    logger = logging.getLogger(__name__)

    content = reformat_to_markdown(ji.fields.description) if ji.fields.description else ''

    if ji.fields.assignee is None:
        resp = None
    else:
        resp = person_map[ji.fields.assignee.name]

    reporter = person_map[ji.fields.reporter.name]

    jiw = jcl.watchers(ji.key)
    watchers = [ person_map[u.name] for u in jiw.watchers ] if jiw else []

    milestone = None
    if ji.fields.fixVersions:
        vnames = [ v.name for v in ji.fields.fixVersions ]
        milestone = vnames[0]
        if len(vnames) > 1:
            logger.warn("{ji.key}: bitbucket issues may have only 1 milestone (JIRA fixVersion); using only first ({f}) and ignoring rest ({r})".format(
                ji=ji, f=milestone, r=",".join(vnames[1:])))

    issue_id = extract_issue_number(ji.key)

    bbi = {
        'status': status_map[ji.fields.status.name],
        'priority': priority_map[ji.fields.priority.name],
        'kind': issuetype_map[ji.fields.issuetype.name],
        'content_updated_on': ji.fields.created,
        'voters': [],
        'title': ji.fields.summary,
        'reporter': reporter,
        'component': None,
        'watchers': watchers,
        'content': content,
        'assignee': resp,
        'created_on': ji.fields.created,
        'version': None,                  # ?
        'edited_on': None,
        'milestone': milestone,
        'updated_on': ji.fields.updated,
        'id': issue_id,
        }

    return bbi


def jira_comment_to_bb_comment(opts,jcl,jc):
    bbc = {
        'content': reformat_to_markdown(jc.body),
        'created_on': jc.created,
        'id': int(jc.id),
        'updated_on': jc.updated,
        'user': person_map[jc.author.name],
        }
    return bbc

def extract_issue_number(jira_issue_key):
    return int(jira_issue_key.split('-')[-1])
def jira_key_to_bb_issue_tag(jira_issue_key):
    return 'issue #' + str(extract_issue_number(jira_issue_key))

def jira_link_text(jk):
    return link("https://invitae.jira.com/browse/"+jk,jk) + " (Invitae access required)"


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    logger = logging.getLogger(__name__)


    opts = parse_args(sys.argv[1:])

    dir_name = opts.jira_project
    if opts.jira_components:
        dir_name += '-' + ','.join(opts.jira_components)

    if opts.jira_issues_file:
        issues = [i.strip() for i in open(opts.jira_issues_file,'r')]
        logger.info("added {n} issues from {opts.jira_issues_file} to issues list".format(n=len(issues),opts=opts))
        opts.jira_issues += issues

    opts.dir = os.path.join('/','tmp',dir_name)
    opts.att_rel_dir = 'attachments'
    opts.att_abs_dir = os.path.join(opts.dir,opts.att_rel_dir)
    opts.json_fn = os.path.join(opts.dir,'db-1.0.json')
    if not os.path.isdir(opts.att_abs_dir):
        os.makedirs(opts.att_abs_dir)

    opts.jira_issues = list(set(opts.jira_issues))   # distinctify

    jcl = JIRA({'server': 'https://{opts.jira_hostname}/'.format(opts=opts)},
        basic_auth=(opts.jira_username,opts.jira_password))


    if opts.existing:
        issues_db = json.load(open(opts.json_fn,'r'))
        existing_ids = [ i['id'] for i in issues_db['issues'] ]
        logger.info("read {n} issues from {fn}".format(n=len(existing_ids),fn=opts.json_fn))
    else:
        issues_db = dict()
        issues_db['meta'] = {
            'default_milestone': None,
            'default_assignee': None,
            'default_kind': "bug",
            'default_component': None,
            'default_version': None,
            }
        issues_db['attachments'] = []
        issues_db['comments'] = []
        issues_db['issues'] = []
        issues_db['logs'] = []

    issues_db['components'] = [ {'name':v.name} for v in jcl.project_components(opts.jira_project) ]
    issues_db['milestones'] = [ {'name':v.name} for v in jcl.project_versions(opts.jira_project) ]
    issues_db['versions'] = issues_db['milestones']


    # bb_issue_map: bb issue # -> bitbucket issue
    bb_issue_map = ImmutableDict( (i['id'],i) for i in issues_db['issues'] )

    # jk_issue_map: jira key -> bitbucket issue
    # contains only items migrated from JIRA (i.e., not preexisting issues with --existing)
    jk_issue_map = ImmutableDict()

    # issue_links is a dict of dicts of lists, using JIRA keys
    # e.g., links['CORE-135']['depends on'] = ['CORE-137']
    issue_links = collections.defaultdict(lambda: collections.defaultdict(lambda: []))


    issues = fetch_issues(opts,jcl)
    logger.info("fetch {n} issues from JIRA".format(n=len(issues)))
    for ji in issues:
        # Pfft. Need to fetch the issue again due to bug in JIRA.
        # See https://bitbucket.org/bspeakmon/jira-python/issue/47/, comment on 2013-10-01 by ssonic
        ji = jcl.issue(ji.key,expand="attachments,comments")

        # create the issue
        bbi = jira_issue_to_bb_issue(opts,jcl,ji)
        issues_db['issues'] += [bbi]

        bb_issue_map[bbi['id']] = bbi
        jk_issue_map[ji.key] = bbi
        issue_links[ji.key]['imported from'] = [jira_link_text(ji.key)]

        # add comments
        for jc in ji.fields.comment.comments:
            bbc = jira_comment_to_bb_comment(opts,jcl,jc)
            bbc['issue'] = bbi['id']
            issues_db['comments'] += [bbc]

        # add attachments
        for ja in ji.fields.attachment:
            att_rel_path = os.path.join(opts.att_rel_dir,ja.id)
            att_abs_path = os.path.join(opts.att_abs_dir,ja.id)

            if not os.path.exists(att_abs_path):
                open(att_abs_path,'w').write(ja.get())
                logger.info("Wrote {att_abs_path}".format(att_abs_path=att_abs_path))
            bba = {
                "path": att_rel_path,
                "issue": bbi['id'],
                "user": person_map[ja.author.name],
                "filename": ja.filename,
                }
            issues_db['attachments'] += [bba]

        # parent-child is task-subtask
        if hasattr(ji.fields,'parent'):
            issue_links[ji.fields.parent.key]['subtasks'].append(jira_key_to_bb_issue_tag(ji.key))
            issue_links[ji.key]['parent task'].append(jira_key_to_bb_issue_tag(ji.fields.parent.key))

        # add links
        for il in ji.fields.issuelinks:
            if hasattr(il,'outwardIssue'):
                issue_links[ji.key][il.type.outward].append(jira_key_to_bb_issue_tag(il.outwardIssue.key))
            elif hasattr(il,'inwardIssue'):
                issue_links[ji.key][il.type.inward].append(jira_key_to_bb_issue_tag(il.inwardIssue.key))


        logger.info("migrated issue {ji.key}: {ji.fields.summary} ({components})".format(
            ji=ji,components=','.join(c.name for c in ji.fields.components)))


    # append links section to content
    # this section shows both task-subtask and "issue link" relationships
    for src,dstlinks in issue_links.iteritems():
        if src not in jk_issue_map:
            logger.warn("issue {src}, with issue_links, not in jk_issue_map; skipping".format(src=src))
            continue

        links_block = "Links\n=====\n"
        for desc,dsts in sorted(dstlinks.iteritems()):
            links_block += "* **{desc}**: {links}  \n".format(desc=desc,links=", ".join(dsts))

        if jk_issue_map[src]['content']:
            jk_issue_map[src]['content'] += "\n\n" + links_block
        else:
            jk_issue_map[src]['content'] = links_block


    id_counts = collections.Counter(i['id'] for i in issues_db['issues'])
    dupes = [ k for k,cnt in id_counts.iteritems() if cnt>1 ]
    if dupes:
        raise RuntimeError("{n} issue ids appear more than once from existing {opts.json_fn}".format(
            n=len(dupes),opts=opts))

    json.dump(issues_db,open(opts.json_fn,'w'))
    logger.info("wrote {n} issues to {opts.json_fn}".format(n=len(id_counts),opts=opts))


    # write zipfile
    os.chdir(opts.dir)
    with zipfile.ZipFile(opts.dir + '.zip','w') as zf:
        for fn in ['db-1.0.json']+glob.glob('attachments/*'):
            zf.write(fn)
            logger.info("added {fn} to archive".format(fn=fn))
Turch
  • 1,546
  • 2
  • 15
  • 31
  • Hi @Turch, I did everything like you described but I'm getting an error when I run the script: `requests.exceptions.ConnectionError: ('Connection aborted.', gaierror(8, 'nodename nor servname provided, or not known'))`. Do you have an idea about this? Thanks! – Fabio Oct 03 '14 at 20:20
  • 1
    @Fabio Looks like it doesn't know what server to connect to, so I would guess an issue with the config file. By default the script looks for it in `~/.config/jira-issues-move-to-bitbucket.conf` (see the comments at the top of the script) but since I was on a Windows machine I replaced that with a local `./jira-issues-move-to-bitbucket.conf`. Although I'm assuming it can read the file since otherwise it would probably give a different error... – Turch Oct 03 '14 at 20:36
  • 1
    Also, are you connecting to a host that supports `HTTPS`? It might not work if it only supports `HTTP` – Turch Oct 03 '14 at 20:41
  • @Turch, I have created the config file already, in the `.config` directory of my home folder under Mac OSX. My hostname was `subdomain.domain.com` but then I realized that it has to be `http://subdomain.domain.com`. But still it doesn't work! I will keep trying! Thanks anyway! – Fabio Oct 03 '14 at 20:41
  • @Turch, ah, really?! My server doesn't support. It is a VPS hosted by Digital Ocean. Are you sure about this? – Fabio Oct 03 '14 at 20:42
  • 1
    @Fabio It was just a guess, I vaguely recalled a setting in Jira to enable the API for unsecured connections, but looking for it now I don't see it and the documentation uses HTTP in the samples. Since you said Mac OSX, [this answer](http://stackoverflow.com/questions/4132525/getaddrinfo-nodename-nor-servname-provided-or-not-known/4987269#4987269) implies that it might be a bug in OSX. See if you can try it on a nix or Windows machine. – Turch Oct 03 '14 at 20:55
  • @Turch, I just enabled `HTTPS` in my Apache server and I get the same error. Therefore, I will try in a *nix system. Thanks a lot for your time! I will let you know as soon as I try in a Debian machine. – Fabio Oct 03 '14 at 21:05
  • @Turch, I just tried in a Debian 7 machine (the same that hosts Jira) and I got a different error now: `requests.exceptions.ConnectionError: ('Connection aborted.', gaierror(-2, 'Name or service not known'))`. No idea what's this. I'm using `http://localhost:8080` as hostname. No luck! Don't know what to do next. Any advice? Thanks! – Fabio Oct 03 '14 at 23:04
  • @Turch, I could export changing part of the line 250 from `https://{opts.jira_hostname}/` to `http://{opts.jira_hostname}/` – Fabio Oct 04 '14 at 15:11
  • @Turch Ive had a go and posted my progress in my question, probably doing something stupid I dont know much about python – Paul Taylor Oct 06 '14 at 16:45
1

NOTE: I'm writing a new answer because writing this in a comment would be horrible, but most of the credit goes to @Turch's answer.

My steps (in OSX and Debian machines, both worked fine):

  1. apt-get install python-pip (Debian) or sudo easy_install pip (OSX)
  2. pip install jira
  3. pip install configparser
  4. easy_install -U setuptools (not sure if really needed)
  5. Download or clone the source code from https://bitbucket.org/reece/rcore/ in your home folder, for example. Note: don't download using pip, it will get the 0.0.2 version and you need the 0.0.3.
  6. Download the Python script created by Reece, mentioned by @Turch, and place it inside of the rcore folder.
  7. Follow the instructions by @Turch: I also had to replace all instances of iteritems with items in the script and in rcore/types/immutabledict.py to make it work with Python 3. You will also need to fill in the dictionaries (priority_map, person_map, etc) with the values your project uses. Finally, you need a config file to exist with the connection info (see comments at the top of the script). Note: I used hostname like jira.domain.com (no http or https).
  8. (This change did the trick for me) I had to change part of the line 250 from 'https://{opts.jira_hostname}/' to 'http://{opts.jira_hostname}/'
  9. To finish, run the script like @Turch mentioned: The basic command line usage is export.py --jira-project <project>
  10. The file was placed in /tmp/.zip for me.
  11. The file was perfectly accepted in the BitBucket importer today.

Hooray for Reece and Turch! Thanks guys!

Fabio
  • 1,757
  • 19
  • 33