搬家神器:一套Python脚本,把GitLab的issues轻松搬到禅道,无损迁移,数据不丢。
1. 引子
作为一个在代码海洋中打滚多年的老码农,最怕的不是写bug,而是换工具。
这不,近日把新接管的一个项目的GitLab issue管理迁移到禅道。几千个issues,几年的历史记录,就像搬家一样,东西多到让人头疼。手动复制?那得猴年马月。买第三方工具?老板说"你们码农不就是干这个的吗?"
好嘛,既然如此,老码农卷起袖子开干——写个脚本,让这些数据自己走过去。
2. 搬家难在哪
核心挑战:两个系统说的不是一种话,住的不是一种房。
这次搬家的难点:
- 房型不同:GitLab和禅道的数据结构差异巨大,字段名、状态值都不一样
- 格式差异:JSON格式要转成SQL语句,还得保证不出错
- 装修风格:GitLab的Markdown要适配禅道的富文本,图片附件得重新安置
- 住户信息:两边的用户账号得建立对应关系,不能张冠李戴
3. 搬家方案
两步走战略:先把东西打包带走,再拆包重新整理。
搬家神器的设计思路:
- 第一步:用GitLab API把所有issues数据抓下来,存成JSON文件
- 第二步:写个转换脚本,把JSON数据变成SQL插入语句
- 关键武器:Python + requests库 + 正则表达式
- 终极目标:原汁原味搬过去,一个都不能少
简单来说,就是告诉GitLab:“你的issues我全要!”,然后告诉禅道:“这些数据按我的格式收好!”
4. 搬家神器登场
第一个法宝:GitLab数据抓取器,一网打尽所有issues。
4.1 抓取工具
import requests
import json
import csv
# GitLab API配置
GITLAB_URL = "https://gitlab.example.com"
PROJECT_ID = 5
TOKEN = "your_gitlab_token"
def get_all_issues():
headers = {"PRIVATE-TOKEN": TOKEN}
issues = []
page = 1
while True:
url = f"{GITLAB_URL}/api/v4/projects/{PROJECT_ID}/issues"
params = {
"per_page": 100,
"page": page,
"state": "all"
}
response = requests.get(url, headers=headers, params=params)
if response.status_code != 200:
print(f"Error: {response.status_code} - {response.text}")
break
page_issues = response.json()
if not page_issues:
break
issues.extend(page_issues)
print(f"Retrieved page {page}, total issues: {len(issues)}")
page += 1
return issues
def export_to_json(issues, filename="gitlab_issues.json"):
with open(filename, 'w', encoding='utf-8') as f:
json.dump(issues, f, indent=2, ensure_ascii=False)
print(f"Exported {len(issues)} issues to {filename}")
# 获取所有issues并导出为JSON
issues = get_all_issues()
export_to_json(issues)
这个小工具的心法:
- 分页抓取,避免一次性加载过多数据撑爆内存
- 错误处理,网络不好也不怕
- 进度显示,让你知道搬了多少箱子了
- JSON存储,便于后续处理和调试
实战效果:一口气把整个issues全抓下来,妥妥的!
4.2 转换神器
第二个法宝:数据格式转换器,把GitLab的话翻译成禅道听得懂的语言。
这个转换器的核心功能:
- 文本清洁工:清理Markdown格式,处理图片和附件
- 状态映射师:根据标签和状态推断严重程度和优先级
- 时间格式化:ISO时间转MySQL时间格式
- SQL生成器:拼装成禅道数据库的INSERT语句
import json
import re
import datetime
import html
def clean_description(description):
"""
清理描述文本,移除GitLab特有的markdown格式
"""
if not description:
return ""
# 移除图片markdown语法并替换为简单文本
description = re.sub(r'!\[(.*?)\]\(/uploads/[^)]+\)', r'[图片: \1]', description)
# 替换GitLab文件链接为简单文本
description = re.sub(r'\[(.*?)\]\(/uploads/[^\)]+\)', r'[附件: \1]', description)
# 转义单引号用于SQL
description = description.replace("'", "''")
return description
def get_severity(issue):
"""
将GitLab issue优先级/严重性映射到禅道严重程度 (1-4)
"""
# 默认严重程度为3(一般)
severity = 3
# 检查可能表示严重程度的标签
if 'labels' in issue and issue['labels']:
for label in issue['labels']:
if 'critical' in label.lower() or 'blocker' in label.lower():
severity = 1 # 致命
elif 'high' in label.lower() or 'major' in label.lower():
severity = 2 # 严重
elif 'low' in label.lower() or 'minor' in label.lower():
severity = 4 # 轻微
return severity
def get_priority(issue):
"""
将GitLab issue优先级映射到禅道优先级 (1-4)
"""
# 默认优先级为3(中)
priority = 3
# 检查可能表示优先级的标签
if 'labels' in issue and issue['labels']:
for label in issue['labels']:
if 'urgent' in label.lower() or 'critical' in label.lower():
priority = 1 # 最高
elif 'high' in label.lower():
priority = 2 # 高
elif 'low' in label.lower():
priority = 4 # 低
return priority
def get_status(issue):
"""
将GitLab issue状态映射到禅道状态 (active, resolved, closed)
"""
if issue['state'] == 'opened':
return 'active'
elif issue['state'] == 'closed':
if 'labels' in issue and any('fixed' in label.lower() for label in issue['labels']):
return 'resolved'
else:
return 'closed'
else:
return 'active' # 默认为激活
def format_date(date_str):
"""
将日期字符串格式化为MySQL日期时间格式或返回NULL
"""
if date_str:
try:
date_obj = datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ")
return f"'{date_obj.strftime('%Y-%m-%d %H:%M:%S')}'"
except (ValueError, TypeError):
try:
# 尝试不带微秒的格式
date_obj = datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%SZ")
return f"'{date_obj.strftime('%Y-%m-%d %H:%M:%S')}'"
except (ValueError, TypeError):
pass
return 'NULL'
def main():
print("开始转换GitLab issues到ZenTao bug表的SQL INSERT语句...")
try:
# 从JSON文件加载GitLab issues
with open('gitlab_issues.json', 'r', encoding='utf-8') as f:
issues = json.loads(f.read())
# 创建SQL文件用于ZenTao导入
with open('zentao_bug_inserts.sql', 'w', encoding='utf-8') as f:
# 写入SQL头部
f.write("-- ZenTao bug表插入语句,由GitLab issues生成\n")
f.write("-- 生成时间: " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n\n")
for issue in issues:
# 提取issue数据
issue_id = issue['iid']
title = html.escape(issue['title']).replace("'", "''")
description = clean_description(issue.get('description', ''))
severity = get_severity(issue)
priority = get_priority(issue)
status = get_status(issue)
assignee = issue.get('assignees', [{}])[0].get('username', '') if issue.get('assignees') else ''
created_date = format_date(issue.get('created_at', ''))
updated_date = format_date(issue.get('updated_at', ''))
closed_date = format_date(issue.get('closed_at', ''))
creator = issue.get('author', {}).get('username', '')
# Bug类型默认为代码错误
bug_type = 'codeerror'
# 默认操作系统和浏览器
os = 'Mac OS'
browser = ''
# 检查是否已确认
confirmed = 1 if issue['state'] == 'closed' else 0
# 确定解决方案
resolution = ''
if issue['state'] == 'closed':
resolution = 'fixed'
# 创建SQL INSERT语句
sql = f"""INSERT INTO `zt_bug`
(`product`, `project`, `module`, `execution`, `plan`, `story`, `task`,
`title`, `keywords`, `severity`, `pri`, `type`, `os`, `browser`,
`steps`, `status`, `confirmed`, `activatedCount`, `activatedDate`,
`openedBy`, `openedDate`, `openedBuild`, `assignedTo`, `assignedDate`,
`deadline`, `resolvedBy`, `resolution`, `resolvedBuild`, `resolvedDate`,
`closedBy`, `closedDate`, `duplicateBug`, `relatedBug`, `case`,
`lastEditedBy`, `lastEditedDate`, `deleted`)
VALUES
(3, 5, 0, 0, 0, 0, 0,
'{title}', '', {severity}, {priority}, '{bug_type}', '{os}', '{browser}',
'{description}', '{status}', {confirmed}, 0, {created_date},
'{creator}', {created_date}, 'trunk', '{assignee}', {updated_date},
NULL, '{assignee if resolution else ''}', '{resolution}', '{resolution if resolution else ''}', {closed_date},
'{assignee if issue['state'] == 'closed' else ''}', {closed_date}, 0, '', 0,
'{creator}', {updated_date}, '0');
"""
f.write(sql + "\n")
print(f"转换完成! 已生成 zentao_bug_inserts.sql 文件,共处理 {len(issues)} 个问题。")
except Exception as e:
print(f"转换过程中出错: {str(e)}")
if __name__ == "__main__":
main()
5. 翻译对照表
核心秘籍:GitLab说的话,禅道这么理解。
搬家过程中的关键翻译:
GitLab原话 | 禅道理解 | 老码农备注 |
---|---|---|
iid | id | 直接搬,不用动脑子 |
title | title | 标题嘛,都一样,注意转义单引号 |
description | steps | 描述变成重现步骤,清理Markdown |
state | status | opened=active,closed看情况 |
labels | severity, pri | 看标签猜严重程度和优先级 |
created_at | openedDate | 时间格式要转换 |
author.username | openedBy | 谁报的bug就是谁 |
assignees[0].username | assignedTo | 指派给第一个人 |
6. 搬家心得
踩坑总结:搬家不易,处处都是细节。
搬家过程中的重点关注:
- 时间翻译官:ISO格式转MySQL格式,NULL值处理要小心
- 文本清洁工:Markdown清理,图片附件重新安置
- SQL防卫兵:单引号转义,防止注入攻击
- 状态翻译员:GitLab状态对应禅道状态,标签推断优先级
- 数据保险箱:先生成临时文件,成功了再正式使用,避免数据丢失
7. 搬家效果
大功告成:issues全部安全落户,一个都没丢。
这次搬家的战果:
- 原有issues全部迁移成功
- 关键信息完整保留
- 用户关系正确映射
- 时间轴保持不变
- SQL执行无错误
用了半天时间,省下来的可是几个月的手工活啊!
8. 后记
搬家感悟:工具在手,天下我有。
这套搬家神器虽然专门为GitLab到禅道设计,但思路是通用的。API抓数据,脚本做转换,SQL批量导入,简单粗暴有效。
下次再碰到类似的数据迁移,照着这个套路,换个API接口,改改字段映射,又是一套新的搬家工具。
老码农搬家格言:数据搬家不求人,脚本在手走天下。
码农秋:在数据迁移路上摸爬滚打的老兵 | 2025-06-24 | 搬家·神器系列