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 ( " < " , " < " )
outtxt = outtxt . replace ( " > " , " > " )
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/> \n Reported 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.