如何将Jira问题导出到BitBucket

Pau*_*lor 5 import export jira bitbucket

我刚把我的项目代码从java.net移到了BitBucket.但我的jira问题跟踪仍然托管在java.net上,虽然BitBucket确实有一些链接到外部问题跟踪器的选项我不认为我可以将它用于java.net,尤其是因为我没有管理员权限需要安装DVCS连接器.

所以我认为另一种选择是导出然后将问题导入BitBucket问题跟踪器,这可能吗?

到目前为止的进展 所以我尝试使用下面的OSX来完成两个信息性答案中的步骤但我遇到了一个问题 - 我对这个脚本实际被调用的内容感到困惑,因为在答案中它讨论了export.py但是没有这样的脚本存在用这个名字,所以我重命名了我下载的那个.

  • sudo easy_install pip(OSX)
  • pip安装jira
  • pip install configparser
  • easy_install -U setuptools
  • 转到https://bitbucket.org/reece/rcore,选择下载选项卡,下载zip并解压缩,然后重命名为reece(由于某种原因,git clone https://bitbucket.org/reece/rcore失败并出现错误)
  • cd reece/rcore
  • 在rcore子文件夹中将脚本另存为export.py.
  • 用import.py中的项替换iteritems
  • 用类型/ immutabledict.py替换iteritems
  • 在rcore文件夹中创建.config

  • 创建包含的.config/jira-issues-move-to-bitbucket.conf

    JIRA-用户名= paultaylor

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

    JIRA密码=密码

  • 运行python export.py --jira-project jaudiotagger

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
Run Code Online (Sandbox Code Playgroud)

我需要像root一样运行pip insdtall

  • sudo pip install configparser

那很有效

但现在

  • python export.py --jira.project jaudiotagger

File "export.py" line 35, in <module?
  from jira.client import JIRA
ImportError: No module named jira.client
Run Code Online (Sandbox Code Playgroud)

Tur*_*rch 3

您可以将问题导入 BitBucket,只需采用适当的格式即可。幸运的是,Reece Hart 已经编写了一个 Python 脚本来连接到 Jira 实例并导出问题。

为了让脚本运行,我必须安装Jira Python 包以及最新版本的rcore(如果您使用 pip,您会得到不兼容的先前版本,因此您必须获取源代码)。我还必须替换脚本和 in 中iteritems的所有实例,以使其与 Python 3 兼容。您还需要使用项目使用的值填写字典(、等)。最后,您需要一个包含连接信息的配置文件(请参阅脚本顶部的注释)。itemsrcore/types/immutabledict.pypriority_mapperson_map

基本的命令行用法是export.py --jira-project <project>

导出数据后,请参阅将问题导入 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))
Run Code Online (Sandbox Code Playgroud)