快刀集(2): 从GitLab到禅道的issue大迁移

发布于:2025-06-26 ⋅ 阅读:(20) ⋅ 点赞:(0)

搬家神器:一套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 | 搬家·神器系列


网站公告

今日签到

点亮在社区的每一天
去签到