【问题标题】:How can I export Jira issues to BitBucket如何将 Jira 问题导出到 BitBucket
【发布时间】:2014-11-08 20:01:52
【问题描述】:

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

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

目前的进展 因此,我尝试使用下面的 OSX 遵循两个信息性答案中的步骤,但我遇到了一个问题 - 我对脚本实际上会被调用什么感到很困惑,因为在答案中它谈到了 export.py 但不存在具有该名称的此类脚本所以我重命名了我下载的那个。

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

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

    jira-username=paultaylor

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

    jira-password=密码

  • 运行 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

我需要以 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

【问题讨论】:

  • pip install configparser 失败还是成功?您的错误是说缺少 configparser。再试一次,看看你是否收到Requirement already satisfied
  • @Fabio 谢谢它尝试重新安装,但我一定错过了一个错误,所以我用 'sudo pip install configparser' 重试并且成功了。但是现在当我重试时,它会抱怨没有名为 jira.client 的模块
  • 哦,我当然需要执行 sudo pip install jira,但现在它在名为 rcore.types.immutabledict 的 NO 模块上失败
  • 按照我的回答中的说明编号 7。

标签: import export jira bitbucket


【解决方案1】:

您可以将问题导入 BitBucket,它们只需要在 appropriate format 中即可。幸运的是,Reece Hart 已经 written a Python script 连接到 Jira 实例并导出问题。

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

基本的命令行用法是export.py --jira-project &lt;project&gt;

导出数据后,请查看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,我做了你描述的所有事情,但是当我运行脚本时出现错误:requests.exceptions.ConnectionError: ('Connection aborted.', gaierror(8, 'nodename nor servname provided, or not known'))。你对此有什么想法吗?谢谢!
  • @Fabio 看起来它不知道要连接到哪个服务器,所以我猜配置文件有问题。默认情况下,脚本会在 ~/.config/jira-issues-move-to-bitbucket.conf 中查找它(请参阅脚本顶部的 cmets),但由于我使用的是 Windows 机器,因此我将其替换为本地 ./jira-issues-move-to-bitbucket.conf。虽然我假设它可以读取文件,否则它可能会给出不同的错误......
  • 另外,您是否连接到支持HTTPS 的主机?如果只支持HTTP 可能不行
  • @Turch,我已经在 Mac OSX 下我的主文件夹的 .config 目录中创建了配置文件。我的主机名是subdomain.domain.com,但后来我意识到它必须是http://subdomain.domain.com。但是还是不行!我会继续努力的!还是谢谢!
  • @Fabio 这只是一个猜测,我隐约记得 Jira 中的一个设置,以启用 API 以进行不安全的连接,但现在寻找它我没有看到它,并且文档在示例中使用了 HTTP .既然你说 Mac OSX,this answer 暗示它可能是 OSX 中的一个错误。看看是否可以在 nix 或 Windows 机器上试用。
【解决方案2】:

注意:我正在写一个新答案,因为在评论中写这个会很糟糕,但大部分功劳归功于@Turch 的答案。

我的步骤(在 OSX 和 Debian 机器上都运行良好):

  1. apt-get install python-pip (Debian) 或 sudo easy_install pip (OSX)
  2. pip install jira
  3. pip install configparser
  4. easy_install -U setuptools(不确定是否真的需要)
  5. 例如,从您的主文件夹中的https://bitbucket.org/reece/rcore/ 下载或克隆源代码。注意:不要使用pip下载,它会得到0.0.2版本,你需要0.0.3
  6. 下载Reece创建的@Turch提到的Python script,并将其放在rcore文件夹中。
  7. 按照@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). 注意:我使用的主机名如jira.domain.com(没有httphttps)。
  8. (此更改对我有用)我不得不将第 250 行的一部分从 'https://{opts.jira_hostname}/' 更改为 'http://{opts.jira_hostname}/'
  9. 要完成,像@Turch 提到的那样运行脚本:The basic command line usage is export.py --jira-project &lt;project&gt;
  10. 该文件为我放置在 /tmp/.zip 中。
  11. 该文件今天已被 BitBucket 导入器完全接受。

Reece 和 Turch 万岁!谢谢大家!

【讨论】:

  • 我尝试过但卡住了,请查看我的问题的更新
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-12-30
  • 2017-05-13
  • 1970-01-01
  • 2012-05-22
相关资源
最近更新 更多