1#!/usr/bin/env python3 2 3# This script is used by maintainers to modify Bugzilla entries in batch 4# mode. 5# Currently it can remove and add a release from/to PRs that are prefixed 6# with '[x Regression]'. Apart from that, it can also change target 7# milestones and optionally enhance the list of known-to-fail versions. 8# 9# The script utilizes the Bugzilla API, as documented here: 10# http://bugzilla.readthedocs.io/en/latest/api/index.html 11# 12# It requires the simplejson, requests, semantic_version packages. 13# In case of openSUSE: 14# zypper in python3-simplejson python3-requests 15# pip3 install semantic_version 16# 17# Sample usages of the script: 18# 19# $ ./maintainer-scripts/branch_changer.py api_key --new-target-milestone=6.2:6.3 \ 20# --comment '6.2 has been released....' --add-known-to-fail=6.2 --limit 3 21# 22# The invocation will set target milestone to 6.3 for all issues that 23# have mistone equal to 6.2. Apart from that, a comment is added to these 24# issues and 6.2 version is added to known-to-fail versions. 25# At maximum 3 issues will be modified and the script will run 26# in dry mode (no issues are modified), unless you append --doit option. 27# 28# $ ./maintainer-scripts/branch_changer.py api_key --new-target-milestone=5.5:6.3 \ 29# --comment 'GCC 5 branch is being closed' --remove 5 --limit 3 30# 31# Very similar to previous invocation, but instead of adding to known-to-fail, 32# '5' release is removed from all issues that have the regression prefix. 33# NOTE: If the version 5 is the only one in regression marker ([5 Regression] ...), 34# then the bug summary is not modified. 35# 36# NOTE: If we change target milestone in between releases and the PR does not 37# regress in the new branch, then target milestone change is skipped: 38# 39# not changing target milestone: not a regression or does not regress with the new milestone 40# 41# $ ./maintainer-scripts/branch_changer.py api_key --add=7:8 42# 43# Aforementioned invocation adds '8' release to the regression prefix of all 44# issues that contain '7' in its regression prefix. 45# 46 47import argparse 48import json 49import re 50import sys 51 52import requests 53 54from semantic_version import Version 55 56base_url = 'https://gcc.gnu.org/bugzilla/rest.cgi/' 57statuses = ['UNCONFIRMED', 'ASSIGNED', 'SUSPENDED', 'NEW', 'WAITING', 'REOPENED'] 58search_summary = ' Regression]' 59regex = r'(.*\[)([0-9\./]*)( [rR]egression])(.*)' 60 61 62class Bug: 63 def __init__(self, data): 64 self.data = data 65 self.versions = None 66 self.fail_versions = [] 67 self.is_regression = False 68 69 self.parse_summary() 70 self.parse_known_to_fail() 71 72 def parse_summary(self): 73 m = re.match(regex, self.data['summary']) 74 if m: 75 self.versions = m.group(2).split('/') 76 self.is_regression = True 77 self.regex_match = m 78 79 def parse_known_to_fail(self): 80 v = self.data['cf_known_to_fail'].strip() 81 if v != '': 82 self.fail_versions = [x for x in re.split(' |,', v) if x != ''] 83 84 def name(self): 85 bugid = self.data['id'] 86 url = f'https://gcc.gnu.org/bugzilla/show_bug.cgi?id={bugid}' 87 if sys.stdout.isatty(): 88 return f'\u001b]8;;{url}\u001b\\PR{bugid}\u001b]8;;\u001b\\ ({self.data["summary"]})' 89 else: 90 return f'PR{bugid} ({self.data["summary"]})' 91 92 def remove_release(self, release): 93 self.versions = list(filter(lambda x: x != release, self.versions)) 94 95 def add_release(self, releases): 96 parts = releases.split(':') 97 assert len(parts) == 2 98 for i, v in enumerate(self.versions): 99 if v == parts[0]: 100 self.versions.insert(i + 1, parts[1]) 101 break 102 103 def add_known_to_fail(self, release): 104 if release in self.fail_versions: 105 return False 106 else: 107 self.fail_versions.append(release) 108 return True 109 110 def update_summary(self, api_key, doit): 111 if not self.versions: 112 print(self.name()) 113 print(' not changing summary, candidate for CLOSING') 114 return False 115 116 summary = self.data['summary'] 117 new_summary = self.serialize_summary() 118 if new_summary != summary: 119 print(self.name()) 120 print(' changing summary to "%s"' % (new_summary)) 121 self.modify_bug(api_key, {'summary': new_summary}, doit) 122 return True 123 124 return False 125 126 def change_milestone(self, api_key, old_milestone, new_milestone, comment, new_fail_version, doit): 127 old_major = Bug.get_major_version(old_milestone) 128 new_major = Bug.get_major_version(new_milestone) 129 130 print(self.name()) 131 args = {} 132 if old_major == new_major: 133 args['target_milestone'] = new_milestone 134 print(' changing target milestone: "%s" to "%s" (same branch)' % (old_milestone, new_milestone)) 135 elif self.is_regression and new_major in self.versions: 136 args['target_milestone'] = new_milestone 137 print(' changing target milestone: "%s" to "%s" (regresses with the new milestone)' 138 % (old_milestone, new_milestone)) 139 else: 140 print(' not changing target milestone: not a regression or does not regress with the new milestone') 141 142 if 'target_milestone' in args and comment: 143 print(' adding comment: "%s"' % comment) 144 args['comment'] = {'comment': comment} 145 146 if new_fail_version: 147 if self.add_known_to_fail(new_fail_version): 148 s = self.serialize_known_to_fail() 149 print(' changing known_to_fail: "%s" to "%s"' % (self.data['cf_known_to_fail'], s)) 150 args['cf_known_to_fail'] = s 151 152 if len(args.keys()) != 0: 153 self.modify_bug(api_key, args, doit) 154 return True 155 else: 156 return False 157 158 def serialize_summary(self): 159 assert self.versions 160 assert self.is_regression 161 162 new_version = '/'.join(self.versions) 163 new_summary = self.regex_match.group(1) + new_version + self.regex_match.group(3) + self.regex_match.group(4) 164 return new_summary 165 166 @staticmethod 167 def to_version(version): 168 if len(version.split('.')) == 2: 169 version += '.0' 170 return Version(version) 171 172 def serialize_known_to_fail(self): 173 assert type(self.fail_versions) is list 174 return ', '.join(sorted(self.fail_versions, key=self.to_version)) 175 176 def modify_bug(self, api_key, params, doit): 177 u = base_url + 'bug/' + str(self.data['id']) 178 179 data = { 180 'ids': [self.data['id']], 181 'api_key': api_key} 182 183 data.update(params) 184 185 if doit: 186 r = requests.put(u, data=json.dumps(data), headers={'content-type': 'text/javascript'}) 187 print(r) 188 189 @staticmethod 190 def get_major_version(release): 191 parts = release.split('.') 192 assert len(parts) == 2 or len(parts) == 3 193 return '.'.join(parts[:-1]) 194 195 @staticmethod 196 def get_bugs(api_key, query): 197 u = base_url + 'bug' 198 r = requests.get(u, params=query) 199 return [Bug(x) for x in r.json()['bugs']] 200 201 202def search(api_key, remove, add, limit, doit): 203 bugs = Bug.get_bugs(api_key, {'api_key': api_key, 'summary': search_summary, 'bug_status': statuses}) 204 bugs = list(filter(lambda x: x.is_regression, bugs)) 205 206 modified = 0 207 for bug in bugs: 208 if remove: 209 bug.remove_release(remove) 210 if add: 211 bug.add_release(add) 212 213 if bug.update_summary(api_key, doit): 214 modified += 1 215 if modified == limit: 216 break 217 218 print('\nModified PRs: %d' % modified) 219 220 221def replace_milestone(api_key, limit, old_milestone, new_milestone, comment, add_known_to_fail, doit): 222 bugs = Bug.get_bugs(api_key, {'api_key': api_key, 'bug_status': statuses, 'target_milestone': old_milestone}) 223 224 modified = 0 225 for bug in bugs: 226 if bug.change_milestone(api_key, old_milestone, new_milestone, comment, add_known_to_fail, doit): 227 modified += 1 228 if modified == limit: 229 break 230 231 print('\nModified PRs: %d' % modified) 232 233 234parser = argparse.ArgumentParser(description='') 235parser.add_argument('api_key', help='API key') 236parser.add_argument('--remove', nargs='?', help='Remove a release from summary') 237parser.add_argument('--add', nargs='?', help='Add a new release to summary, e.g. 6:7 will add 7 where 6 is included') 238parser.add_argument('--limit', nargs='?', help='Limit number of bugs affected by the script') 239parser.add_argument('--doit', action='store_true', help='Really modify BUGs in the bugzilla') 240parser.add_argument('--new-target-milestone', help='Set a new target milestone, ' 241 'e.g. 8.5:9.4 will set milestone to 9.4 for all PRs having milestone set to 8.5') 242parser.add_argument('--add-known-to-fail', help='Set a new known to fail ' 243 'for all PRs affected by --new-target-milestone') 244parser.add_argument('--comment', help='Comment a PR for which we set a new target milestore') 245 246args = parser.parse_args() 247# Python3 does not have sys.maxint 248args.limit = int(args.limit) if args.limit else 10**10 249 250if args.remove or args.add: 251 search(args.api_key, args.remove, args.add, args.limit, args.doit) 252if args.new_target_milestone: 253 t = args.new_target_milestone.split(':') 254 assert len(t) == 2 255 replace_milestone(args.api_key, args.limit, t[0], t[1], args.comment, args.add_known_to_fail, args.doit) 256