For a long time, the QEMU project hosted its git repository on their own server and used Launchpad for tracking bugs. The self-hosting of the git repository caused some troubles, so the project switched the main repository to Gitlab in January 2021. That change of course also triggered the question whether the bug tracking could be moved from Launchpad to Gitlab, too. This would provide a better integration of the bug tracking with the git repository, and also has the advantage that more QEMU developers have a Gitlab account than a Launchpad account. But after some discussions it was clear that there was the desire to not simply leave the opened bug tickets at Launchpad behind, so for being able to switch, those tickets needed to be migrated to the Gitlab issue tracker instead.

Fortunately, there are APIs for both, Launchpad and Gitlab, so although I was a complete Python newbie, I was indeed able to build a little script that transfers bug tickets from Launchpad to Gitlab. I recently found the script on my hard disk again, and I thought it might maybe be helpful for other people in the same situation, so here it is:

#!/usr/bin/env python3

import argparse
import os
import re
import sys
import time
import gitlab
import textwrap

from launchpadlib.launchpad import Launchpad
import lazr.restfulclient.errors

parser = argparse.ArgumentParser(description=
                                 "Copy bugs from Launchpad to Gitlab")
parser.add_argument('-l',
                    '--lp-project-name',
                    dest='lp_project_name',
                    help='The Launchpad project name.')
parser.add_argument('-g',
                    '--gl-project-id',
                    dest='gl_project_id',
                    help='The Gitlab project ID.')
parser.add_argument('--verbose', '-v',
                    help='Enable debug logging.',
                    action="store_true")
parser.add_argument('--open', '-o',
                    dest='open_url',
                    help='Open URLs in browser.',
                    action="store_true")
parser.add_argument('--anonymous', '-a',
                    help='Use anonymous login to launchpad (no updates!)',
                    action="store_true")
parser.add_argument('--search-text', '-s',
                    dest='search_text',
                    help='Look up bugs by searching for text.')
parser.add_argument('--reporter', '-r',
                    dest='reporter',
                    help='Look up bugs from the given reporter only.')
parser.add_argument('-b',
                    '--batch-size',
                    dest='batch_size',
                    default=20,
                    type=int,
                    help='The maximum amount of bug tickets to handle.')
args = parser.parse_args()


def get_launchpad():
    cache_dir = os.path.expanduser("~/.launchpadlib/cache/")
    if not os.path.exists(cache_dir):
        os.makedirs(cache_dir, 0o700)

    def no_credential():
        print("ERROR: Can't proceed without Launchpad credential.")
        sys.exit()

    if args.anonymous:
        launchpad = Launchpad.login_anonymously(args.lp_project_name +
                                                '-bugs',
                                                'production', cache_dir)
    else:
        launchpad = Launchpad.login_with(args.lp_project_name + '-bugs',
                                         'production',
                                         cache_dir,
                                         credential_save_failed=no_credential)
    return launchpad

def convert_tags(tags):
    convtab = {
        "cve": "Security",
        "disk": "Storage",
        "documentation": "Documentation",
        "ethernet": "Networking",
        "feature-request": "kind::Feature Request",
        "linux": "os: Linux",
        "macos": "os: macOS",
        "security": "Security",
        "test": "Tests",
        "tests": "Tests",
    }

    labels = []
    for tag in tags:
        label = convtab.get(tag)
        if label:
            labels.append(label)
    return labels

def show_bug_task(bug_task):
    print('*** %s - %s' % (bug_task.bug.web_link,
                           str(bug_task.bug.title)[0:44] + "..."))
    if args.verbose:
        print('### Description: %s' % bug_task.bug.description)
        print('### Tags: %s' % bug_task.bug.tags)
        print('### Status: %s' % bug_task.status)
        print('### Assignee: %s' % bug_task.assignee)
        print('### Owner: %s' % bug_task.owner)
        for attachment in bug_task.bug.attachments:
            print('#### Attachment: %s (%s)'
                  % (attachment.data_link, attachment.title))
            #print(sorted(attachment.lp_attributes))
        for message in bug_task.bug.messages:
            print('#### Message: %s' % message.content)

def mark_lp_bug_moved(bug_task, new_url):
    subject = "Moved bug report"
    comment = """
This is an automated cleanup. This bug report has been moved to the
new bug tracker on gitlab.com and thus gets marked as 'expired' now.
Please continue with the discussion here:

 %s
""" % new_url

    bug_task.status = "Expired"
    bug_task.assignee = None
    try:
        bug_task.lp_save()
        bug_task.bug.newMessage(subject=subject, content=comment)
        if args.verbose:
            printf(" ... expired LP bug report %s" % bug_task.web_link)
    except lazr.restfulclient.errors.ServerError as e:
        print("ERROR: Timeout while saving LP bug update! (%s)" % e, end='')
    except Exception as e:
        print("ERROR: Failed to save LP bug update! (%s)" % e, end='')

def preptext(txt):
    txtwrapper = textwrap.TextWrapper(replace_whitespace = False,
                                      break_long_words = False,
                                      drop_whitespace = True, width = 74)
    outtxt = ""
    for line in txt.split("\n"):
        outtxt += txtwrapper.fill(line) + "\n"
    outtxt = outtxt.replace("-", "-")
    outtxt = outtxt.replace("<", "&lt;")
    outtxt = outtxt.replace(">", "&gt;")
    return outtxt

def transfer_to_gitlab(launchpad, project, bug_task):
    bug = bug_task.bug
    desc = "This bug has been copied automatically from: " \
           + bug_task.web_link \
           + "<br/>\nReported by '[" + bug.owner.display_name \
           + "](https://launchpad.net/~" + bug.owner.name + ")' "
    desc += "on " \
           + bug.date_created.date().isoformat() + " :\n\n" \
           + "<pre>" + preptext(bug.description) + "</pre>\n"
    issue = project.issues.create({'title': bug.title, 'description': desc},
                                  retry_transient_errors = True)
    for msg in bug.messages:
        has_attachment = False
        attachtxt = "\n**Attachments:**\n\n"
        for attachment in bug_task.bug.attachments:
            if attachment.message == msg:
                has_attachment = True
                attachtxt += "* [" + attachment.title + "](" \
                             + attachment.data_link + ")\n"
        note = "Comment from '[" + msg.owner.display_name \
            + "](" + msg.owner.web_link + ")' on Launchpad (" \
            + msg.date_created.date().isoformat() + "):\n"
        if msg == bug.messages[0] or not msg.content.strip():
            if not has_attachment:
                continue
        else:
            note += "\n<pre>" + preptext(msg.content) + "</pre>\n"
        if has_attachment:
            note += attachtxt
        issue.notes.create({'body': note}, retry_transient_errors = True)
        time.sleep(0.2)  # To avoid "spamming"
    labels = convert_tags(bug.tags)
    labels.append("Launchpad")
    issue.labels = labels
    issue.save(retry_transient_errors = True)
    print("    ==> %s" % issue.web_url)
    if not args.anonymous:
        mark_lp_bug_moved(bug_task, issue.web_url)
    if args.open_url:
        os.system("xdg-open " + issue.web_url)


def main():
    print("LP2GL", args)

    if not args.lp_project_name:
        print("Please specify a Launchpad project name (with -l)")
        return

    launchpad = get_launchpad()
    lp_project = launchpad.projects[args.lp_project_name]
    if args.reporter:
        bug_tasks = lp_project.searchTasks(
            status=["New", "Confirmed", "Triaged"],
            bug_reporter="https://api.launchpad.net/1.0/~" + args.reporter,
            omit_duplicates=True,
            order_by="datecreated")
    elif args.search_text:
        bug_tasks = lp_project.searchTasks(
            status=["New", "Confirmed", "Triaged", "In Progress"],
            search_text=args.search_text,
            omit_duplicates=True,
            order_by="datecreated")
    else:
        bug_tasks = lp_project.searchTasks(
            status=["New", "Confirmed", "Triaged"],
            omit_duplicates=True,
            order_by="datecreated")

    if args.gl_project_id:
        try:
            priv_token = os.environ['GITLAB_PRIVATE_TOKEN']
        except Exception as e:
            print("Please set the GITLAB_PRIVATE_TOKEN env variable!")
            return

        gl = gitlab.Gitlab('https://gitlab.com', private_token=priv_token)
        gl.auth()
        project = gl.projects.get(args.gl_project_id)
    else:
        print("Provide a Gitlab project ID to transfer the bugs ('-g')")

    batch_size = args.batch_size
    for bug_task in bug_tasks:
        if batch_size < 1 :
            break
        owner = bug_task.owner.name
        if args.open_url:
            os.system("xdg-open " + bug_task.bug.web_link)
        show_bug_task(bug_task)
        if args.gl_project_id:
            time.sleep(2)  # To avoid "spamming"
            transfer_to_gitlab(launchpad, project, bug_task)
        batch_size -= 1

    print("All done.")

if __name__ == '__main__':
    main()

You need to specify at least a Launchpad project name with the -l parameter (for example -l qemu-kvm), and for simple initial tests it might be good to use -a for an anonymous Launchpad login, too (the Launchpad ticket won’t be updated in that case). Without further parameters, this will just list the tickets in the Launchpad project that are still opened.

To transfer tickets to a Gitlab issue tracker, you need to specify the Gitlab project ID with the -g parameter (which can be found on the main page of your project on Gitlab) and provide a Gitlab access token for the API via the GITLAB_PRIVATE_TOKEN environment variable.

Anyway, if you want to use the script, I recommend to test it with anonymous access for Launchpad (i.e. with the -a parameter) and a dummy project on Gitlab first (which you can just delete afterwards). This way you can get a basic understanding and impression of the script first, before you use it for the final transfer of your bug tickets.