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