Python操作Jira实现不同项目之间的Issue同步

前言

1.Jira系统基本介绍

‌Jira 是一款专为软件团队设计的敏捷项目管理与问题追踪工具,提供灵活的工作流定制和高效协作功能,是开发团队管理任务、缺陷和项目的核心平台。

关于Jira系统我相信做研发的伙伴都不陌生,一般做项目管理/bug管理等都需要用到。常见的项目管理平台有:禅道,Jira,Devops.....等等,此处就不过多赘述了。

2.需求介绍

简单介绍一下需求;我方是产品供应商,产品在客户那边测试时用Jira管理issues,本次项目的需求是将客户Jira系统中的issue同步至我方的Jira系统中,便于我方管理和追踪!

封装Jira的基本操作

1.方案介绍

对于Jira的操作方案主要分为两种,第一种是使用python的jira库来对Jira系统进行操作;第二种就是直接使用requests库来调用Jira REST API。

通常工程效率上:决定先用 jira ,不足再补 requests 方案是最省事的路线!

2.二次封装

安装jira库
python 复制代码
python -m pip install jira
二次封装jira库

新建一个JiraTool的类,将常用的方法封装成函数,方便后续调用。关于函数的用法可参考注释,特意加了中文注释。

python 复制代码
import os
import re
import pathlib
from jira import JIRA
import paramiko

class JiraTool(object):
    def __init__(self, server, username, password):
        self.server = str(server)
        self.username = str(username)
        self.password = str(password)
        self.jira_conn = JIRA(options={'server': self.server}, basic_auth=(self.username, self.password))  # jira服务器,用户名密码

    def get_projects(self):
        """访问权限的项目列表:[<JIRA Project: key='AR2022011', name='识别', id='12882'>,...]"""
        # for p in self.jira_conn.projects():
        #     print(p.key, p.id, p.name)
        return self.jira_conn.projects()

    def get_project(self, project_id):
        """
        通过项目id/key获取项目主要属性:
        key: 项目的Key
        name: 项目名称
        description: 项目描述
        lead: 项目负责人
        projectCategory: 项目分类
        components: 项目组件
        versions: 项目中的版本
        raw: 项目的原始API数据
        """
        project = {
            'key': self.jira_conn.project(project_id).key,
            'name': self.jira_conn.project(project_id).name,
            'description': self.jira_conn.project(project_id).description,
            'lead': self.jira_conn.project(project_id).lead,
            'components': self.jira_conn.project(project_id).components,
            'versions': self.jira_conn.project(project_id).versions,
            'raw': self.jira_conn.project(project_id).raw
        }

        return project

    def search_jira_issues(self, jql=None, maxnum: int = None):
        """根据jql查询jira,返回[<JIRA Issue: key='PJT-9141', id='302682'>,...]"""
        maxResults = -1 if maxnum is None else maxnum
        issues = self.jira_conn.search_issues(jql, maxResults=maxResults, json_result=False)

        return issues

    def create_issue(self, issue_dict):
        """
        创建issue,issue_dict = {
        'project': {'id': 10000},
        'issuetype': {'name': 'Task'}
        'summary': 'BUG描述',
        'description': 'BUG详情 \n换行',
        'priority': {'name': 'BUG优先级'},
        'labels': ['标签'],
        'issuetype': {'name': '问题类型-故障'},
        'assignee':{'name': '经办人'} #经办人
        }
        """
        return self.jira_conn.create_issue(fields=issue_dict)

    def create_issues(self, issue_list):
        """
        批量创建issue,issue_list = [issue_dict1, issue_dict2, issue_dict3]
        """
        self.jira_conn.create_issues(issue_list)

    def get_issue(self, issueID):
        """获取issue信息"""
        issue = self.jira_conn.issue(issueID)

        return issue

    def get_issuefields(self, issueID):
        """获取issue-fields信息:"""
        issuefields = self.get_issue(issueID).fields

        fields = {
            'summary': issuefields.summary,
            'assignee': issuefields.assignee,
            'status': issuefields.status,
            'issuetype': issuefields.issuetype,
            'reporter': issuefields.reporter,
            'labels': issuefields.labels,
            'priority': issuefields.priority.name,
            'description': issuefields.description,
            'duedate': issuefields.duedate,
            'created': issuefields.created,
            'comments': issuefields.comment.comments,
            'versions': issuefields.versions,
            'fixVersions': issuefields.fixVersions,
        }

        return fields

    def get_summary(self, issueID):
        """获取issue-summary信息:"""
        issuefields = self.get_issue(issueID).fields
        return issuefields.summary

    def get_issuelabels(self, issueID):
        """获取issue-issuelabels信息:"""
        issuefields = self.get_issue(issueID).fields
        labels = issuefields.labels
        return labels

    def get_status(self, issueID):
        """查询状态"""
        return self.jira_conn.issue(issueID).fields.status

    def get_assignee(self, issueID):
        """查询assignee"""
        return self.jira_conn.issue(issueID).fields.assignee.name

    def find_description(self, issueID):
        """查询description信息"""
        return self.jira_conn.issue(issueID).fields.description

    def update_issue(self, issueID, issue_dict):
        """
        创建issue,issue_dict = {
        'project': {'id': 10000},
        'summary': 'BUG描述',
        'description': 'BUG详情 \n换行',
        'priority': {'name': 'BUG优先级'},
        'labels': ['标签'],
        'issuetype': {'name': '问题类型-故障'},
        'assignee':{'name': '经办人'} #经办人
        }
        update(assignee={'name': username})
        """
        self.jira_conn.issue(issueID).update(issue_dict)

    def get_versions(self, jira_key):  # 获取Jira影响版本
        versions = [v.name for v in self.jira_conn.issue(jira_key).fields.versions]
        return versions

    def add_version(self, jira_key, versions_name):  # 为Jira添加影响版本,注意新增的版本在JIRA中是否存在,否则报错
        self.jira_conn.issue(jira_key).add_field_value('versions', {'name': versions_name})

    def del_version(self, jira_key, versions_name):  # 获取Jira影响版本
        oldversions = [i.name for i in self.jira_conn.issue(jira_key).fields.versions]
        newversions = oldversions
        if versions_name in oldversions:
            oldversions.remove(versions_name)
            newversions = oldversions
        versions = [{'name': f} for f in newversions]
        self.jira_conn.issue(jira_key).update(fields={'versions': versions})

    def get_fixversions(self, jira_key):  # 获取Jira影响版本
        fixVersions = [v.name for v in self.jira_conn.issue(jira_key).fields.fixVersions]
        return fixVersions

    def add_fixversions(self, jira_key, fixversions_name):  # 为Jira添加解决版本,注意新增的版本在JIRA中是否存在,否则报错
        self.jira_conn.issue(jira_key).add_field_value('fixVersions', {'name': fixversions_name})

    def del_fixversions(self, jira_key, versions_name):  # 获取Jira影响版本
        oldfixversions = [i.name for i in self.jira_conn.issue(jira_key).fields.fixVersions]
        newfixversions = oldfixversions
        if versions_name in oldfixversions:
            newfixversions.remove(versions_name)
            newfixversions = oldfixversions
        versions = [{'name': f} for f in newfixversions]
        self.jira_conn.issue(jira_key).update(fields={'fixVersions': versions})

    def add_field_value(self, issueID, key, value):
        issue = self.jira_conn.issue(issueID)
        issue.add_field_value(field=key, value=value)

    def add_attachment(self, issueID, filePath):
        """上传附件到指定的issue, 如果sftp=enable则将附件上传至FTP"""
        issueID = str(issueID).strip()
        filePath = pathlib.Path(filePath).resolve()

        fileSize = filePath.stat().st_size
        sftp_enable = Utils.get_the_conf_value(section='FTP SETTING', option='sftp_enable')

        if fileSize > 57671680:  # size limit is 55Mb
            logging.warning('The size of attachment:{!s} is more than 55Mb, skip this attachment.'.format(filePath))
            return
        else:
            if Utils.is_truthy(sftp_enable):
                remoteFileName = '{:s}_[EXT_Attachment]_{:s}'.format(issueID, filePath.name)
                Utils.upload_file_to_sftp(filePath, remoteFileName=remoteFileName)
            else:
                self.jira_conn.add_attachment(issueID, str(filePath))
                logging.info('Attachment:{!s} was attached to: {:s}'.format(filePath.name, issueID))

    def download_attachment(self, attachment, filename=None, directory=None):
        filename = str(filename).strip() if filename is not None else '[EXT attachment]{}'.format(attachment.filename)
        directory = pathlib.Path(directory) if directory is not None else pathlib.Path('.').parent / 'Attachments'

        filePath = directory / filename

        with open(filePath, 'wb') as f:
            f.write(self.jira_conn.attachment(attachment.id).get())

        return filePath.absolute()

    def add_comment(self, issueID, context, filePath=None):
        """添加comment"""
        if filePath is None or not os.path.exists(filePath):
            comment = context
        else:
            self.add_attachment(issueID, filePath)
            filePath = os.path.basename(filePath)
            comment = f"{context}\r\n!{filePath}|thumbnail!"

        self.jira_conn.add_comment(issueID, comment)

        logging.info('New comment was add to: {:s}'.format(issueID))

    def update_comment(self, issueID, comment_id, body):
        """更新comment"""
        issue = self.jira_conn.issue(issueID)
        comment = self.jira_conn.comment(issue, comment_id)
        comment.update(body=body)

    def update_status(self, issueID=None, status=None, **kwargs):
        """更新问题流程状态"""
        issue = self.jira_conn.issue(issueID)
        self.jira_conn.transition_issue(issue, status, **kwargs)

    def close_client(self):  # 关闭jira链接
        self.jira_conn.close()
封装工具类

除了封装常用的jira方法外,还封装了一个工具类Utils,主要负责FTP的相关操作;因为在两个Project之间同步issue时,需要将issue的附件上传至FTP服务器备份。

python 复制代码
class Utils(object):
    
    @staticmethod
    def save_last_sync_time(filePath=None):
        """
        将此次同步的时间记入last_sync.txt中
        :param filePath: .\last_sync.txt
        :return:
        """
        filePath = pathlib.Path(filePath) if filePath is not None else pathlib.Path('.').parent / 'last_sync.txt'

        current_date = datetime.datetime.now()
        current_date = current_date.replace(tzinfo=datetime.datetime.now().astimezone().tzinfo)
        timestamp = current_date.strftime("%Y-%m-%dT%H:%M:%S.%f%z")

        with open(filePath, 'w+') as time_file:
            time_file.write(timestamp)

        logging.info("Last updated time {} was saved".format(timestamp))

    @ staticmethod
    def get_last_sync_time(filePath=None):
        """
        读取last_sync.txt中上次执行同步的时间
        :param filePath: .\last_sync.txt
        :return: lastUpdatedTime
        """

        filePath = pathlib.Path(filePath) if filePath is not None else pathlib.Path('.').parent / 'last_sync.txt'

        with open(filePath, 'r') as time_file:
            lastUpdatedTime = time_file.read()

        return lastUpdatedTime

    @staticmethod
    def convert_timestamp(time_str):
        """
        将Jira上的时间字符串转换为datetime对象
        :param time_str: 2024-01-31T17:31:05.000+0800
        :return: datetime.datetime(2024, 1, 31, 17, 31, 5, tzinfo=datetime.timezone(datetime.timedelta(seconds=28800)))
        """
        return datetime.datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%S.%f%z")

    @staticmethod
    def convert_last_sync_time(filePath=None):
        datetime_t = Utils.convert_timestamp(Utils.get_last_sync_time(filePath))

        return datetime.datetime.strftime(datetime_t, '%Y/%m/%d %H:%M')

    @staticmethod
    def _open_sftp_connection(host, username, password, port):
        host = str(host).strip()
        username = str(username).strip()
        password = str(password).strip()
        port = int(port)

        try:
            client = paramiko.SSHClient()
            client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
            client.connect(host, username=username, password=password)
            transport = client.get_transport()

            sftp = paramiko.SFTPClient.from_transport(transport)

        except Exception as err:
            logging.error('Login sftp failed:{}'.format(err))
            return

        return sftp

    @staticmethod
    def _close_sftp_connection(sftp: paramiko.SFTPClient):
        sftp.close()
        logging.info('Close sftp connection...')

    # 将附件上传至ftp服务器
    @staticmethod
    def upload_file_to_sftp(filePath, host=None, username=None, password=None, remoteFileName=None, port=None):
        filePath = pathlib.Path(filePath).resolve()
        host = str(host) if host is not None else Utils.get_the_conf_value(section='FTP SETTING', option='sftp_host')
        username = str(username) if username is not None else Utils.get_the_conf_value(section='FTP SETTING', option='sftp_user')
        password = str(password) if password is not None else Utils.get_the_conf_value(section='FTP SETTING', option='sftp_pwd')
        remoteFileName = str(remoteFileName) if remoteFileName is not None else filePath.name
        port = int(port) if port is not None else Utils.get_the_conf_value(section='FTP SETTING', option='sftp_port')

        remoteDir = Utils.get_the_conf_value(section='FTP SETTING', option='sftp_upload_dir')
        remotePath = (pathlib.Path(remoteDir) / remoteFileName).resolve().as_posix()

        if not filePath.exists():
            raise FileNotFoundError('{!s} not found.'.format(filePath))

        sftp = Utils._open_sftp_connection(host, username, password, port)

        try:
            sftp.put(filePath, remotePath)
        except Exception as err:
            logging.error("Put file {!s} to sftp failed:{:s}.".format(filePath.name, err))
        else:
            logging.info('Put {:s} to ftp server success.'.format(filePath.name))
        finally:
            Utils._close_sftp_connection(sftp)

同步Issue

1.封装同步新issue的方法:
python 复制代码
def jira_sync_new_issues(sourceJira: JiraTool, targetJira: JiraTool, issues: [list, set], sourceProj: str, targetProj: str):
    """
    将source Jira系统中的issues 同步至target Jira系统
    :param sourceJira: 源头Jira系统
    :param targetJira: 目标Jira系统
    :param issues:  需要被同步的issues列表
    :param sourceProj: 源头Jira项目名称
    :param targetProj: 目标Jira项目名称
    :return: None
    """
    for issue in issues:
        issue = sourceJira.get_issue(issue)

        # step_1-创建新issue
        issueDict = {
            'issuetype': {'name': issue.fields.issuetype.name},
            'project': targetProj,
            'summary': '[sync{!s}]{!s}'.format(issue.key, issue.fields.summary),
            'description': str(issue.fields.description),
            'duedate': issue.fields.duedate,
            'priority': {'name': issue.fields.priority.name},
        }
        newIssue = targetJira.create_issue(issueDict)
        logging.info('In source project: {:s} created new issue:{} success.(synced from issue:{:s} of {:s})'.format(targetProj, newIssue.key, issue.key, sourceProj))

        # step_2-添加comments
        for comment in issue.fields.comment.comments:
            text = '[Ext comment]\n Creater:{:s}  Created:{}\n{:s}'.format(comment.author.displayName, comment.created, comment.body)
            targetJira.add_comment(newIssue.key, context=text)

        # step_3-添加attachments
        for attachment in issue.fields.attachment:
            filePath = sourceJira.download_attachment(attachment)
            targetJira.add_attachment(newIssue.key, filePath)
2.逻辑判断以及功能实现

具体思路如下:

**Step 1:**筛选出源头项目中的 Issue ID

**Step 2:**筛选出目标项目中 Issue ID

**Step 3:**比对两者 Issues ID的列表中不同的部分(只对非重叠部分的issue处理)

**Step 4:**对非重叠部分的 Issue,在目标项目中新建

python 复制代码
    # source:源头Jira
    sourceJira = JiraTool(server=source_jira_url, username=source_jira_username, password=source_jira_password)
    # Step 1:筛选出源头项目中的 Issue ID
    sourceKey = list(map(lambda x: x.key, sourceJira.search_jira_issues(jql=filter_to_be_synced)))
    logging.info('Filtered from source project issue keys:{}'.format(sourceKey))

    # target:目标Jira
    targetJira = JiraTool(server=target_jira_url, username=target_jira_username, password=target_jira_password)

    # 从目标Jira中筛选出已经同步的issues
    # Step 2:筛选出目标项目中 Issue ID
    filterAlreadySynced = list(map(lambda x: targetJira.get_issue(x).fields.summary, targetJira.search_jira_issues(jql='project = {:s} and summary ~ "*{:s}*"'.format(targetProject, sourceProject))))
    alreadySyncedKey = [re.search(r'sync(?P<key>[A-Z]+-\d+)', s).group('key') for s in filterAlreadySynced]

    logging.info('Already synced issue keys:{}'.format(alreadySyncedKey))
    
    # Step 3:比对两者 Issues ID的列表中不同的部分
    if len(sourceKey) == 0:
        logging.info('The number of issues to be synchronized is 0. Check the filtering criteria.')
    else:
        if len(alreadySyncedKey) == 0:
            logging.info('This is the first synchronization...')
            needToBeCreatedIssues = [sourceJira.jira_conn.issue(issueID) for issueID in sourceKey]
            logging.info('New issues need to be created:{}'.format(needToBeCreatedIssues))
            # 在目标Jira的project中新建issues
            # Step 4:对非重叠部分的 Issue,在目标项目中新建
            jira_sync_new_issues(sourceJira, targetJira, needToBeCreatedIssues, sourceProject, targetProject)

这里需要着重提一下这行代码:

这里涉及到了JQL的概念,关于JQL语法参考我的这篇文章JQL语法介绍

python 复制代码
# jql=filter_to_be_synced  ------> project = xxx AND summary ~"Network issue"
sourceKey = list(map(lambda x: x.key, sourceJira.search_jira_issues(jql=filter_to_be_synced)))

总结

该需求总体上看没有特别大的难点,主要的难点在于两个Project之间issue同步处理的逻辑细节,比如:如何比对两个项目的issue,源issue的comments/attachment的处理,以及初次同步后如果源 issue又出现了更新如何处理? ....等等!

虽然总体上看没有block的点,但是实际开发的过程中又很多细节需要不断的与需求方进行确认。其次就是功能完成后的测试工作(需要用实际的Project来测试),因为该功能初步实现后在实际的测试过程中暴露出了不少问题。

以上!

相关推荐
@zulnger3 分钟前
python 学习笔记(异常对象)
笔记·python·学习
No0d1es8 分钟前
2025年12月 GESP CCF编程能力等级认证Python七级真题
python·青少年编程·gesp·ccf
Hello.Reader9 分钟前
PyFlink Table API Data Types DataType 是什么、UDF 类型声明怎么写、Python / Pandas 类型映射一文搞懂
python·php·pandas
嫂子的姐夫11 分钟前
013-webpack:新东方
爬虫·python·webpack·node.js·逆向
CCPC不拿奖不改名11 分钟前
python基础:python语言的数据结构+面试习题
开发语言·数据结构·python·面试
eybk12 分钟前
拖放pdf转化为txt文件多进程多线程合并分词版
java·python·pdf
APIshop17 分钟前
Python 爬虫获取「item_video」——淘宝商品主图视频全流程拆解
爬虫·python·音视频
数据大魔方18 分钟前
【期货量化实战】威廉指标(WR)策略:精准捕捉超买超卖信号(Python源码)
开发语言·数据库·python·算法·github·程序员创富
天天睡大觉19 分钟前
Python学习6
windows·python·学习
亮子AI19 分钟前
【Python】Typer应用如何打包为Windows下的.exe文件?
开发语言·windows·python