前言
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来测试),因为该功能初步实现后在实际的测试过程中暴露出了不少问题。
以上!