2017-01-17 00:53:10 -06:00
#!/usr/bin/python
from __future__ import print_function
import argparse
import bugzilla
import dateutil . parser
from datetime import timedelta , datetime
from dateutil . tz import tzlocal
import os
2017-05-29 12:17:27 +02:00
from random import shuffle
2017-01-17 00:53:10 -06:00
import requests . exceptions
import subprocess
import sys
import tempfile
2017-05-30 10:23:49 +02:00
from xmlrpclib import Fault
2017-01-17 00:53:10 -06:00
import yaml
from xml . etree import cElementTree as ET
import osc . conf
import osc . core
from osclib . cache import Cache
2017-06-14 23:50:28 -05:00
from osclib . core import package_list
2017-01-17 00:53:10 -06:00
# Issue summary can contain unicode characters and therefore a string containing
# either summary or one in which ISSUE_SUMMARY is then placed must be unicode.
# For example, translation-update-upstream contains bsc#877707 which has a
# unicode character in its summary.
2017-05-29 12:23:39 +02:00
BUG_SUMMARY = ' [patch-lost-in-sle] Missing issues in {factory} / {package} '
2017-01-17 00:53:10 -06:00
BUG_TEMPLATE = u ' {message_start} \n \n {issues} '
2017-02-07 21:54:12 -06:00
MESSAGE_START = ' The following issues were referenced in the changelog for {project} / {package} , but where not found in {factory} / {package} after {newest} days. Review the issues and submit changes to {factory} to ensure all relevant changes end up in {factory} which is used as the basis for the next SLE version. For more information and details on how to go about submitting the changes see https://mailman.suse.de/mlarch/SuSE/research/2017/research.2017.02/msg00051.html. '
2017-01-17 00:53:10 -06:00
ISSUE_SUMMARY = u ' [ {label} ]( {url} ) owned by {owner} : {summary} '
2017-05-29 14:38:58 +02:00
ISSUE_SUMMARY_BUGZILLA = u ' {label} owned by {owner} : {summary} '
2017-01-17 00:53:10 -06:00
ISSUE_SUMMARY_PLAIN = u ' [ {label} ]( {url} ) '
2017-05-29 14:38:58 +02:00
ISSUE_SUMMARY_PLAIN_BUGZILLA = u ' {label} '
2017-01-17 00:53:10 -06:00
2017-02-07 21:47:50 -06:00
def bug_create ( bugzilla_api , meta , assigned_to , cc , summary , description ) :
2017-01-17 00:53:10 -06:00
createinfo = bugzilla_api . build_createbug (
product = meta [ 0 ] ,
component = meta [ 1 ] ,
version = meta [ 2 ] ,
severity = ' normal ' ,
op_sys = ' Linux ' ,
platform = ' PC ' ,
2017-02-07 21:47:50 -06:00
assigned_to = assigned_to ,
2017-01-17 00:53:10 -06:00
cc = cc ,
summary = summary ,
description = description )
newbug = bugzilla_api . createbug ( createinfo )
return newbug . id
2017-05-29 12:46:09 +02:00
def entity_email ( apiurl , entity , key ) :
url = osc . core . makeurl ( apiurl , ( entity , key ) )
2017-02-07 21:47:50 -06:00
root = ET . parse ( osc . core . http_GET ( url ) ) . getroot ( )
email = root . find ( ' email ' )
return email . text if email is not None else None
2017-05-29 12:46:09 +02:00
def bug_owner ( apiurl , package , entity = ' person ' ) :
2017-02-07 21:47:50 -06:00
query = {
' binary ' : package ,
}
url = osc . core . makeurl ( apiurl , ( ' search ' , ' owner ' ) , query = query )
root = ET . parse ( osc . core . http_GET ( url ) ) . getroot ( )
2017-05-29 12:46:09 +02:00
bugowner = root . find ( ' .// {} [@role= " bugowner " ] ' . format ( entity ) )
2017-02-07 21:47:50 -06:00
if bugowner is not None :
2017-05-29 12:46:09 +02:00
return entity_email ( apiurl , entity , bugowner . get ( ' name ' ) )
maintainer = root . find ( ' .// {} [@role= " maintainer " ] ' . format ( entity ) )
2017-02-07 21:47:50 -06:00
if maintainer is not None :
2017-05-29 12:46:09 +02:00
return entity_email ( apiurl , entity , maintainer . get ( ' name ' ) )
if entity == ' person ' :
return bug_owner ( apiurl , package , ' group ' )
2017-02-07 21:47:50 -06:00
return None
2017-01-17 00:53:10 -06:00
def bug_meta_get ( bugzilla_api , bug_id ) :
2017-05-30 11:10:45 +02:00
try :
bug = bugzilla_api . getbug ( bug_id )
2017-10-16 22:47:58 +02:00
except Fault as e :
2017-05-30 11:10:45 +02:00
print ( ' bug_meta_get(): ' + str ( e ) )
return None
2017-05-29 12:18:22 +02:00
return bug . component
2017-01-17 00:53:10 -06:00
def bug_meta ( bugzilla_api , defaults , trackers , issues ) :
# Extract meta from the first bug from bnc tracker or fallback to defaults.
prefix = trackers [ ' bnc ' ] [ : 3 ]
for issue in issues :
if issue . startswith ( prefix ) :
2017-05-29 12:18:22 +02:00
component = bug_meta_get ( bugzilla_api , issue [ 4 : ] )
2017-05-30 11:10:45 +02:00
if component :
return ( defaults [ 0 ] , component , defaults [ 2 ] )
2017-01-17 00:53:10 -06:00
return defaults
def bugzilla_init ( apiurl ) :
bugzilla_api = bugzilla . Bugzilla ( apiurl )
if not bugzilla_api . logged_in :
print ( ' Bugzilla credentials required to create bugs. ' )
bugzilla_api . interactive_login ( )
return bugzilla_api
def prompt_continue ( change_count ) :
allowed = [ ' y ' , ' b ' , ' s ' , ' n ' , ' ' ]
if change_count > 0 :
print ( ' File bug for {} issues and continue? [y/b/s/n/?] (y): ' . format ( change_count ) , end = ' ' )
else :
print ( ' No changes for which to create bug, continue? [y/b/s/n/?] (y): ' , end = ' ' )
response = raw_input ( ) . lower ( )
if response == ' ? ' :
print ( ' b = break; file bug if applicable, record in db, and stop \n s = skip package ' )
elif response in allowed :
if response == ' ' :
response = ' y '
return response
else :
print ( ' Invalid response: {} ' . format ( response ) )
return prompt_continue ( change_count )
def prompt_interactive ( changes , project , package ) :
with tempfile . NamedTemporaryFile ( suffix = ' .yml ' ) as temp :
temp . write ( yaml . safe_dump ( changes , default_flow_style = False , default_style = " ' " ) + ' \n ' )
temp . write ( ' # {} / {} \n ' . format ( project , package ) )
temp . write ( ' # comment or remove lines to whitelist issues ' )
temp . flush ( )
editor = os . getenv ( ' EDITOR ' )
if not editor :
editor = ' xdg-open '
subprocess . call ( [ editor , temp . name ] )
changes_after = yaml . safe_load ( open ( temp . name ) . read ( ) )
if changes_after is None :
changes_after = { }
return changes_after
def issue_found ( package , label , db ) :
return not ( package not in db or db [ package ] is None or label not in db [ package ] )
def issue_trackers ( apiurl ) :
url = osc . core . makeurl ( apiurl , [ ' issue_trackers ' ] )
root = ET . parse ( osc . core . http_GET ( url ) ) . getroot ( )
trackers = { }
for tracker in root . findall ( ' issue-tracker ' ) :
trackers [ tracker . find ( ' name ' ) . text ] = tracker . find ( ' label ' ) . text
return trackers
def issue_normalize ( trackers , tracker , name ) :
if tracker in trackers :
return trackers [ tracker ] . replace ( ' @@@ ' , name )
2017-05-29 13:57:57 +02:00
print ( ' WARNING: ignoring unknown tracker {} for {} ' . format ( tracker , name ) )
return None
2017-01-17 00:53:10 -06:00
def issues_get ( apiurl , project , package , trackers , db ) :
issues = { }
url = osc . core . makeurl ( apiurl , [ ' source ' , project , package ] , { ' view ' : ' issues ' } )
root = ET . parse ( osc . core . http_GET ( url ) ) . getroot ( )
now = datetime . now ( tzlocal ( ) ) # Much harder than should be.
for issue in root . findall ( ' issue ' ) :
# Normalize issues to active API instance issue-tracker definitions.
# Assumes the two servers have the name trackers, but different labels.
label = issue_normalize ( trackers , issue . find ( ' tracker ' ) . text , issue . find ( ' name ' ) . text )
2017-05-29 13:57:57 +02:00
if label is None :
continue
2017-01-17 00:53:10 -06:00
# Ignore already processed issues.
if issue_found ( package , label , db ) :
continue
summary = issue . find ( ' summary ' )
if summary is not None :
summary = summary . text
owner = issue . find ( ' owner/email ' )
if owner is not None :
owner = owner . text
created = issue . find ( ' created_at ' )
updated = issue . find ( ' updated_at ' )
if created is not None and created . text is not None :
date = created . text
elif updated is not None and updated . text is not None :
date = updated . text
else :
# Old date to make logic work.
date = ' 2007-12-12 00:00 GMT+1 '
date = dateutil . parser . parse ( date )
delta = now - date
issues [ label ] = {
' url ' : issue . find ( ' url ' ) . text ,
' summary ' : summary ,
' owner ' : owner ,
' age ' : delta . days ,
}
return issues
def git_clone ( url , directory ) :
return_code = subprocess . call ( [ ' git ' , ' clone ' , url , directory ] )
if return_code != 0 :
raise Exception ( ' Failed to clone {} ' . format ( url ) )
def sync ( config_dir , db_dir ) :
cwd = os . getcwd ( )
devnull = open ( os . devnull , ' wb ' )
git_sync_dir = os . path . join ( config_dir , ' git-sync ' )
git_sync_exec = os . path . join ( git_sync_dir , ' git-sync ' )
if not os . path . exists ( git_sync_dir ) :
os . makedirs ( git_sync_dir )
git_clone ( ' https://github.com/simonthum/git-sync.git ' , git_sync_dir )
else :
os . chdir ( git_sync_dir )
subprocess . call ( [ ' git ' , ' pull ' , ' origin ' , ' master ' ] , stdout = devnull , stderr = devnull )
if not os . path . exists ( db_dir ) :
os . makedirs ( db_dir )
git_clone ( ' git@github.com:jberry-suse/osc-plugin-factory-issue-db.git ' , db_dir )
os . chdir ( db_dir )
subprocess . call ( [ ' git ' , ' config ' , ' --bool ' , ' branch.master.sync ' , ' true ' ] )
subprocess . call ( [ ' git ' , ' config ' , ' --bool ' , ' branch.master.syncNewFiles ' , ' true ' ] )
subprocess . call ( [ ' git ' , ' config ' , ' branch.master.syncCommitMsg ' , ' Sync issue-diff.py changes. ' ] )
os . chdir ( db_dir )
return_code = subprocess . call ( [ git_sync_exec ] )
if return_code != 0 :
raise Exception ( ' Failed to sync local db changes. ' )
os . chdir ( cwd )
2017-05-30 12:46:07 +02:00
def print_stats ( db ) :
bug_ids = [ ]
reported = 0
whitelisted = 0
for package , bugs in db . items ( ) :
if bugs == ' whitelist ' :
continue
for reference , outcome in bugs . items ( ) :
if outcome != ' whitelist ' :
bug_ids . append ( int ( outcome ) )
reported + = 1
else :
whitelisted + = 1
print ( ' Packages: {} ' . format ( len ( db ) ) )
print ( ' Bugs: {} ' . format ( len ( set ( bug_ids ) ) ) )
print ( ' Reported: {} ' . format ( reported ) )
print ( ' Whitelisted: {} ' . format ( whitelisted ) )
2017-01-17 00:53:10 -06:00
def main ( args ) :
# Store the default apiurl in addition to the overriden url if the
# option was set and thus overrides the default config value.
# Using the OBS link does not work for ?view=issues.
if args . apiurl is not None :
osc . conf . get_config ( )
apiurl_default = osc . conf . config [ ' apiurl ' ]
else :
apiurl_default = None
osc . conf . get_config ( override_apiurl = args . apiurl )
osc . conf . config [ ' debug ' ] = args . debug
apiurl = osc . conf . config [ ' apiurl ' ]
Cache . init ( )
db_dir = os . path . join ( args . config_dir , ' issue-db ' )
db_file = os . path . join ( db_dir , ' {} .yml ' . format ( args . project ) )
sync ( args . config_dir , db_dir )
if os . path . exists ( db_file ) :
db = yaml . safe_load ( open ( db_file ) . read ( ) )
if db is None :
db = { }
else :
print ( ' Loaded db file: {} ' . format ( db_file ) )
else :
db = { }
2017-05-30 12:46:07 +02:00
if args . print_stats :
print_stats ( db )
return
2017-01-17 00:53:10 -06:00
print ( ' Comparing {} against {} ' . format ( args . project , args . factory ) )
bugzilla_api = bugzilla_init ( args . bugzilla_apiurl )
bugzilla_defaults = ( args . bugzilla_product , args . bugzilla_component , args . bugzilla_version )
trackers = issue_trackers ( apiurl )
packages_project = package_list ( apiurl , args . project )
packages_factory = package_list ( apiurl_default , args . factory )
packages = set ( packages_project ) . intersection ( set ( packages_factory ) )
new = 0
2017-05-29 12:17:27 +02:00
shuffle ( list ( packages ) )
2017-05-30 10:22:04 +02:00
for index , package in enumerate ( packages , start = 1 ) :
if index % 50 == 0 :
print ( ' Checked {} of {} ' . format ( index , len ( packages ) ) )
2017-05-29 13:59:05 +02:00
if package in db and db [ package ] == ' whitelist ' :
print ( ' Skipping package {} ' . format ( package ) )
continue
2017-01-17 00:53:10 -06:00
issues_project = issues_get ( apiurl , args . project , package , trackers , db )
issues_factory = issues_get ( apiurl_default , args . factory , package , trackers , db )
missing_from_factory = set ( issues_project . keys ( ) ) - set ( issues_factory . keys ( ) )
# Filtering by age must be done after set diff in order to allow for
# matches with issues newer than --newest.
for label in set ( missing_from_factory ) :
if issues_project [ label ] [ ' age ' ] < args . newest :
missing_from_factory . remove ( label )
if len ( missing_from_factory ) == 0 :
continue
print ( ' {} : {} missing ' . format ( package , len ( missing_from_factory ) ) )
# Generate summaries for issues missing from factory.
changes = { }
for issue in missing_from_factory :
info = issues_project [ issue ]
2017-05-29 14:38:58 +02:00
summary = ISSUE_SUMMARY if info [ ' owner ' ] else ISSUE_SUMMARY_PLAIN
2017-01-17 00:53:10 -06:00
changes [ issue ] = summary . format (
label = issue , url = info [ ' url ' ] , owner = info [ ' owner ' ] , summary = info [ ' summary ' ] )
# Prompt user to decide which issues to whitelist.
changes_after = prompt_interactive ( changes , args . project , package )
# Determine if any real changes (vs typos) and create text issue list.
issues = [ ]
cc = [ ]
if len ( changes_after ) > 0 :
for issue , summary in changes . items ( ) :
if issue in changes_after :
2017-05-29 14:38:58 +02:00
info = issues_project [ issue ]
if issue . startswith ( ' bsc ' ) :
# Reformat for bugzilla markdown.
summary = ISSUE_SUMMARY_BUGZILLA if info [ ' owner ' ] else ISSUE_SUMMARY_PLAIN_BUGZILLA
issue = issue . replace ( ' bsc ' , ' bug ' )
summary = summary . format (
label = issue , url = info [ ' url ' ] , owner = info [ ' owner ' ] , summary = info [ ' summary ' ] )
2017-01-17 00:53:10 -06:00
issues . append ( ' - ' + summary )
2017-05-29 14:38:58 +02:00
if info [ ' owner ' ] is not None :
cc . append ( info [ ' owner ' ] )
2017-01-17 00:53:10 -06:00
# Prompt user about how to continue.
response = prompt_continue ( len ( issues ) )
if response == ' n ' :
break
if response == ' s ' :
continue
# File a bug if not all issues whitelisted.
if len ( issues ) > 0 :
summary = BUG_SUMMARY . format ( project = args . project , factory = args . factory , package = package )
message = BUG_TEMPLATE . format (
message_start = MESSAGE_START . format (
project = args . project , factory = args . factory , package = package , newest = args . newest ) ,
issues = ' \n ' . join ( issues ) )
2017-05-30 10:48:55 +02:00
if len ( message ) > 65535 :
# Truncate messages longer than bugzilla limit.
message = message [ : 65535 - 3 ] + ' ... '
2017-01-17 00:53:10 -06:00
# Determine bugzilla meta information to use when creating bug.
meta = bug_meta ( bugzilla_api , bugzilla_defaults , trackers , changes . keys ( ) )
2017-02-07 21:47:50 -06:00
owner = bug_owner ( apiurl , package )
2017-01-17 00:53:10 -06:00
if args . bugzilla_cc :
cc . append ( args . bugzilla_cc )
2017-05-30 10:23:49 +02:00
# Try to create bug, but allow for handling faults.
tries = 0
while tries < 10 :
try :
bug_id = bug_create ( bugzilla_api , meta , owner , cc , summary , message )
break
2017-10-16 22:47:58 +02:00
except Fault as e :
2017-05-30 10:23:49 +02:00
if ' There is no component named ' in e . faultString :
print ( ' Invalid component {} , fallback to default ' . format ( meta [ 1 ] ) )
meta = ( meta [ 0 ] , bugzilla_defaults [ 1 ] , meta [ 2 ] )
elif ' is not a valid username ' in e . faultString :
username = e . faultString . split ( ' ' , 3 ) [ 2 ]
cc . remove ( username )
print ( ' Removed invalid username {} ' . format ( username ) )
else :
raise e
tries + = 1
2017-01-17 00:53:10 -06:00
# Mark changes in db.
notified , whitelisted = 0 , 0
for issue in changes :
if package not in db :
db [ package ] = { }
if issue in changes_after :
db [ package ] [ issue ] = str ( bug_id )
notified + = 1
else :
db [ package ] [ issue ] = ' whitelist '
whitelisted + = 1
# Write out changes after each package to avoid loss.
with open ( db_file , ' w ' ) as outfile :
yaml . safe_dump ( db , outfile , default_flow_style = False , default_style = " ' " )
if notified > 0 :
2017-05-29 12:19:34 +02:00
print ( ' {} : {} notified in bug {} , {} whitelisted ' . format ( package , notified , bug_id , whitelisted ) )
2017-01-17 00:53:10 -06:00
else :
print ( ' {} : {} whitelisted ' . format ( package , whitelisted ) )
if response == ' b ' :
break
new + = 1
if new == args . limit :
print ( ' stopped at limit ' )
break
sync ( args . config_dir , db_dir )
if __name__ == ' __main__ ' :
description = ' Compare packages from a project against factory for differences in referenced issues and ' \
' present changes to allow whitelisting before creating bugzilla entries. A database is kept ' \
' of previously handled issues to avoid repeats and kept in sync via a git repository. '
parser = argparse . ArgumentParser ( description = description )
parser . add_argument ( ' -A ' , ' --apiurl ' , default = ' https://api.suse.de ' , metavar = ' URL ' , help = ' OBS instance API URL ' )
parser . add_argument ( ' --bugzilla-apiurl ' , required = True , metavar = ' URL ' , help = ' bugzilla API URL ' )
2017-05-29 12:20:07 +02:00
parser . add_argument ( ' --bugzilla-product ' , default = ' SUSE Linux Enterprise Server 15 ' , metavar = ' PRODUCT ' , help = ' default bugzilla product ' )
2017-01-17 00:53:10 -06:00
parser . add_argument ( ' --bugzilla-component ' , default = ' Other ' , metavar = ' COMPONENT ' , help = ' default bugzilla component ' )
2017-05-29 12:20:07 +02:00
parser . add_argument ( ' --bugzilla-version ' , default = ' unspecified ' , metavar = ' VERSION ' , help = ' default bugzilla version ' )
2017-01-17 00:53:10 -06:00
parser . add_argument ( ' --bugzilla-cc ' , metavar = ' EMAIL ' , help = ' bugzilla address added to cc on all bugs created ' )
parser . add_argument ( ' -d ' , ' --debug ' , action = ' store_true ' , help = ' print info useful for debugging ' )
parser . add_argument ( ' -f ' , ' --factory ' , default = ' openSUSE:Factory ' , metavar = ' PROJECT ' , help = ' factory project to use as baseline for comparison ' )
2017-02-07 21:48:10 -06:00
parser . add_argument ( ' -p ' , ' --project ' , default = ' SUSE:SLE-12-SP3:GA ' , metavar = ' PROJECT ' , help = ' project to check for issues that have are not found in factory ' )
2017-01-17 00:53:10 -06:00
parser . add_argument ( ' --newest ' , type = int , default = ' 30 ' , metavar = ' AGE_IN_DAYS ' , help = ' newest issues to be considered ' )
parser . add_argument ( ' --limit ' , type = int , default = ' 0 ' , help = ' limit number of packages with new issues processed ' )
parser . add_argument ( ' --config-dir ' , help = ' configuration directory containing git-sync tool and issue db ' )
2017-05-30 12:46:07 +02:00
parser . add_argument ( ' --print-stats ' , action = ' store_true ' , help = ' print statistics based on database ' )
2017-01-17 00:53:10 -06:00
args = parser . parse_args ( )
if args . config_dir is None :
args . config_dir = os . path . expanduser ( ' ~/.osc-plugin-factory ' )
sys . exit ( main ( args ) )