]>
Commit | Line | Data |
---|---|---|
9f95a23c TL |
1 | #!/usr/bin/env python3 |
2 | # | |
3 | # backport-resolve-issue | |
4 | # | |
5 | # Based on "backport-create-issue", which was itself based on work by | |
6 | # by Loic Dachary. | |
7 | # | |
8 | # | |
9 | # Introduction | |
10 | # ============ | |
11 | # | |
12 | # This script processes GitHub backport PRs, checking for proper cross-linking | |
13 | # with a Redmine Backport tracker issue and, if a PR is merged and properly | |
14 | # cross-linked, it can optionally resolve the tracker issue and correctly | |
15 | # populate the "Target version" field. | |
16 | # | |
17 | # The script takes a single positional argument, which is optional. If the | |
18 | # argument is an integer, it is assumed to be a GitHub backport PR ID (e.g. "28549"). | |
19 | # In this mode ("single PR mode") the script processes a single GitHub backport | |
20 | # PR and terminates. | |
21 | # | |
22 | # If the argument is not an integer, or is missing, it is assumed to be a | |
23 | # commit (SHA1 or tag) to start from. If no positional argument is given, it | |
24 | # defaults to the tag "BRI-{release}", which might have been added by the last run of the | |
25 | # script. This mode is called "scan merge commits mode". | |
26 | # | |
27 | # In both modes, the script scans a local git repo, which is assumed to be | |
28 | # in the current working directory. In single PR mode, the script will work | |
29 | # only if the PR's merge commit is present in the current branch of the local | |
30 | # git repo. In scan merge commits mode, the script starts from the given SHA1 | |
31 | # or tag, taking each merge commit in turn and attempting to obtain the GitHub | |
32 | # PR number for each. | |
33 | # | |
34 | # For each GitHub PR, the script interactively displays all relevant information | |
35 | # (NOTE: this includes displaying the GitHub PR and Redmine backport issue in | |
36 | # web browser tabs!) and prompts the user for her preferred disposition. | |
37 | # | |
38 | # | |
39 | # Assumptions | |
40 | # =========== | |
41 | # | |
42 | # Among other things, the script assumes: | |
43 | # | |
44 | # 1. it is being run in the top-level directory of a Ceph git repo | |
45 | # 2. the preferred web browser is Firefox and the command to open a browser | |
46 | # tab is "firefox" | |
47 | # 3. if Firefox is running and '--no-browser' was not given, the Firefox window | |
48 | # is visible to the user and the user desires to view GitHub PRs and Tracker | |
49 | # Issues in the browser | |
50 | # 4. if Firefox is not running, the user does not want to view PRs and issues | |
51 | # in a web browser | |
52 | # | |
53 | # | |
54 | # Dependencies | |
55 | # ============ | |
56 | # | |
57 | # To run this script, first install the dependencies | |
58 | # | |
59 | # virtualenv v | |
60 | # source v/bin/activate | |
61 | # pip install gitpython python-redmine | |
62 | # | |
63 | # Then, copy the script from src/script/backport-resolve-issue (in the branch | |
64 | # "master" - the script is not maintained anywhere else) to somewhere in your | |
65 | # PATH. | |
66 | # | |
67 | # Finally, run the script with appropriate parameters. For example: | |
68 | # | |
69 | # backport-resolve-issue --key $MY_REDMINE_KEY | |
70 | # backport-resolve-issue --user $MY_REDMINE_USER --password $MY_REDMINE_PASSWORD | |
71 | # | |
72 | # | |
73 | # Copyright Notice | |
74 | # ================ | |
75 | # | |
76 | # Copyright (C) 2019, SUSE LLC | |
77 | # | |
78 | # Author: Nathan Cutler <ncutler@suse.com> | |
79 | # | |
80 | # This program is free software: you can redistribute it and/or modify | |
81 | # it under the terms of the GNU Affero General Public License as | |
82 | # published by the Free Software Foundation, either version 3 of the | |
83 | # License, or (at your option) any later version. | |
84 | # | |
85 | # This program is distributed in the hope that it will be useful, | |
86 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
87 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
88 | # GNU Affero General Public License for more details. | |
89 | # | |
90 | # You should have received a copy of the GNU Affero General Public License | |
91 | # along with this program. If not, see http://www.gnu.org/licenses/> | |
92 | # | |
93 | import argparse | |
94 | import logging | |
95 | import json | |
96 | import os | |
97 | import re | |
98 | import sys | |
99 | import time | |
100 | from redminelib import Redmine # https://pypi.org/project/python-redmine/ | |
101 | from redminelib.exceptions import ResourceAttrError | |
102 | from git import Repo | |
103 | from git.exc import GitCommandError | |
104 | ||
105 | github_endpoint = "https://github.com/ceph/ceph" | |
106 | redmine_endpoint = "https://tracker.ceph.com" | |
107 | project_name = "Ceph" | |
108 | status2status_id = {} | |
109 | project_id2project = {} | |
110 | tracker2tracker_id = {} | |
111 | version2version_id = {} | |
112 | delay_seconds = 5 | |
113 | browser_cmd = "firefox" | |
114 | no_browser = False | |
115 | ceph_release = None | |
116 | dry_run = False | |
117 | redmine = None | |
118 | bri_tag = None | |
119 | github_token_file = "~/.github_token" | |
120 | github_token = None | |
121 | github_user = None | |
122 | redmine_key_file = "~/.redmine_key" | |
123 | redmine_key = None | |
124 | ||
125 | def browser_running(): | |
126 | global browser_cmd | |
127 | retval = os.system("pgrep {} >/dev/null".format(browser_cmd)) | |
128 | if retval == 0: | |
129 | return True | |
130 | return False | |
131 | ||
132 | def ceph_version(repo, sha1=None): | |
133 | if sha1: | |
134 | return repo.git.describe('--match', 'v*', sha1).split('-')[0] | |
135 | return repo.git.describe('--match', 'v*').split('-')[0] | |
136 | ||
137 | def commit_range(args): | |
138 | global bri_tag | |
139 | if len(args.pr_or_commit) == 0: | |
140 | return '{}..HEAD'.format(bri_tag) | |
141 | elif len(args.pr_or_commit) == 1: | |
142 | pass | |
143 | else: | |
144 | logging.warn("Ignoring positional parameters {}".format(args.pr_or_commit[1:])) | |
145 | commit = args.pr_or_commit[0] | |
146 | return '{}..HEAD'.format(commit) | |
147 | ||
148 | def connect_to_redmine(a): | |
149 | global redmine_key | |
150 | global redmine_key_file | |
151 | redmine_key = read_from_file(redmine_key_file) | |
152 | if a.user and a.password: | |
153 | logging.info("Redmine username and password were provided; using them") | |
154 | return Redmine(redmine_endpoint, username=a.user, password=a.password) | |
155 | elif redmine_key: | |
156 | logging.info("Redmine key was read from '%s'; using it" % redmine_key_file) | |
157 | return Redmine(redmine_endpoint, key=redmine_key) | |
158 | else: | |
159 | usage() | |
160 | ||
161 | def derive_github_user_from_token(gh_token): | |
162 | retval = None | |
163 | if gh_token: | |
164 | curl_opt = "-u :{} --silent".format(gh_token) | |
165 | cmd = "curl {} https://api.github.com/user".format(curl_opt) | |
166 | logging.debug("Running curl command ->{}<-".format(cmd)) | |
167 | json_str = os.popen(cmd).read() | |
168 | github_api_result = json.loads(json_str) | |
169 | if "login" in github_api_result: | |
170 | retval = github_api_result['login'] | |
171 | if "message" in github_api_result: | |
172 | assert False, \ | |
173 | "GitHub API unexpectedly returned ->{}<-".format(github_api_result['message']) | |
174 | return retval | |
175 | ||
176 | def ensure_bri_tag_exists(repo, release): | |
177 | global bri_tag | |
178 | bri_tag = "BRI-{}".format(release) | |
179 | bri_tag_exists = '' | |
180 | try: | |
181 | bri_tag_exists = repo.git.show_ref(bri_tag) | |
182 | except GitCommandError as err: | |
183 | logging.error(err) | |
184 | logging.debug("git show-ref {} returned ->{}<-".format(bri_tag, bri_tag_exists)) | |
185 | if not bri_tag_exists: | |
186 | c_v = ceph_version(repo) | |
187 | logging.info("No {} tag found: setting it to {}".format(bri_tag, c_v)) | |
188 | repo.git.tag(bri_tag, c_v) | |
189 | ||
190 | def get_issue_release(redmine_issue): | |
191 | for field in redmine_issue.custom_fields: | |
192 | if field['name'] == 'Release': | |
193 | return field['value'] | |
194 | return None | |
195 | ||
196 | def get_project(r, p_id): | |
197 | if p_id not in project_id2project: | |
198 | p_obj = r.project.get(p_id, include='trackers') | |
199 | project_id2project[p_id] = p_obj | |
200 | return project_id2project[p_id] | |
201 | ||
202 | def has_tracker(r, p_id, tracker_name): | |
203 | for tracker in get_project(r, p_id).trackers: | |
204 | if tracker['name'] == tracker_name: | |
205 | return True | |
206 | return False | |
207 | ||
208 | def parse_arguments(): | |
209 | parser = argparse.ArgumentParser() | |
210 | parser.add_argument("--user", help="Redmine user") | |
211 | parser.add_argument("--password", help="Redmine password") | |
212 | parser.add_argument("--debug", help="Show debug-level messages", | |
213 | action="store_true") | |
214 | parser.add_argument("--dry-run", help="Do not write anything to Redmine", | |
215 | action="store_true") | |
216 | parser.add_argument("--no-browser", help="Do not use web browser even if it is running", | |
217 | action="store_true") | |
218 | parser.add_argument("pr_or_commit", nargs='*', | |
219 | help="GitHub PR ID, or last merge commit successfully processed") | |
220 | return parser.parse_args() | |
221 | ||
222 | def populate_ceph_release(repo): | |
223 | global ceph_release | |
224 | current_branch = repo.git.rev_parse('--abbrev-ref', 'HEAD') | |
225 | release_ver_full = ceph_version(repo) | |
226 | logging.info("Current git branch is {}, {}".format(current_branch, release_ver_full)) | |
227 | release_ver = release_ver_full.split('.')[0] + '.' + release_ver_full.split('.')[1] | |
228 | try: | |
229 | ceph_release = ver_to_release()[release_ver] | |
230 | except KeyError: | |
231 | assert False, \ | |
232 | "Release version {} does not correspond to any known stable release".format(release_ver) | |
233 | logging.info("Ceph release is {}".format(ceph_release)) | |
234 | ||
235 | def populate_status_dict(r): | |
236 | for status in r.issue_status.all(): | |
237 | status2status_id[status.name] = status.id | |
238 | logging.debug("Statuses {}".format(status2status_id)) | |
239 | return None | |
240 | ||
241 | def populate_tracker_dict(r): | |
242 | for tracker in r.tracker.all(): | |
243 | tracker2tracker_id[tracker.name] = tracker.id | |
244 | logging.debug("Trackers {}".format(tracker2tracker_id)) | |
245 | return None | |
246 | ||
247 | # not used currently, but might be useful | |
248 | def populate_version_dict(r, p_id): | |
249 | versions = r.version.filter(project_id=p_id) | |
250 | for version in versions: | |
251 | version2version_id[version.name] = version.id | |
252 | return None | |
253 | ||
254 | def print_inner_divider(): | |
255 | print("-----------------------------------------------------------------") | |
256 | ||
257 | def print_outer_divider(): | |
258 | print("=================================================================") | |
259 | ||
260 | def process_merge(repo, merge, merges_remaining): | |
261 | backport = None | |
262 | sha1 = merge.split(' ')[0] | |
263 | possible_to_resolve = True | |
264 | try: | |
265 | backport = Backport(repo, merge_commit_string=merge) | |
266 | except AssertionError as err: | |
267 | logging.error("Malformed backport due to ->{}<-".format(err)) | |
268 | possible_to_resolve = False | |
269 | if tag_merge_commits: | |
270 | if possible_to_resolve: | |
271 | prompt = ("[a] Abort, " | |
272 | "[i] Ignore and advance {bri} tag, " | |
273 | "[u] Update tracker and advance {bri} tag (default 'u') --> " | |
274 | .format(bri=bri_tag) | |
275 | ) | |
276 | default_input_val = "u" | |
277 | else: | |
278 | prompt = ("[a] Abort, " | |
279 | "[i] Ignore and advance {bri} tag (default 'i') --> " | |
280 | .format(bri=bri_tag) | |
281 | ) | |
282 | default_input_val = "i" | |
283 | else: | |
284 | if possible_to_resolve: | |
285 | prompt = "[a] Abort, [i] Ignore, [u] Update tracker (default 'u') --> " | |
286 | default_input_val = "u" | |
287 | else: | |
288 | if merges_remaining > 1: | |
289 | prompt = "[a] Abort, [i] Ignore --> " | |
290 | default_input_val = "i" | |
291 | else: | |
292 | return False | |
293 | input_val = input(prompt) | |
294 | if input_val == '': | |
295 | input_val = default_input_val | |
296 | if input_val.lower() == "a": | |
297 | exit(-1) | |
298 | elif input_val.lower() == "i": | |
299 | pass | |
300 | else: | |
301 | input_val = "u" | |
302 | if input_val.lower() == "u": | |
303 | if backport: | |
304 | backport.resolve() | |
305 | else: | |
306 | logging.warn("Cannot determine which issue to resolve. Ignoring.") | |
307 | if tag_merge_commits: | |
308 | if backport: | |
309 | tag_sha1(repo, backport.merge_commit_sha1) | |
310 | else: | |
311 | tag_sha1(repo, sha1) | |
312 | return True | |
313 | ||
314 | def read_from_file(fs): | |
315 | retval = None | |
316 | full_path = os.path.expanduser(fs) | |
317 | try: | |
318 | with open(full_path, "r") as f: | |
319 | retval = f.read().strip() | |
320 | except FileNotFoundError: | |
321 | pass | |
322 | return retval | |
323 | ||
324 | def releases(): | |
325 | return ('argonaut', 'bobtail', 'cuttlefish', 'dumpling', 'emperor', | |
326 | 'firefly', 'giant', 'hammer', 'infernalis', 'jewel', 'kraken', | |
327 | 'luminous', 'mimic', 'nautilus') | |
328 | ||
329 | def report_params(a): | |
330 | global dry_run | |
331 | global no_browser | |
332 | if a.dry_run: | |
333 | dry_run = True | |
334 | logging.warning("Dry run: nothing will be written to Redmine") | |
335 | if a.no_browser: | |
336 | no_browser = True | |
337 | logging.warning("Web browser will not be used even if it is running") | |
338 | ||
339 | def set_logging_level(a): | |
340 | if a.debug: | |
341 | logging.basicConfig(level=logging.DEBUG) | |
342 | else: | |
343 | logging.basicConfig(level=logging.INFO) | |
344 | return None | |
345 | ||
346 | def tag_sha1(repo, sha1): | |
347 | global bri_tag | |
348 | repo.git.tag('--delete', bri_tag) | |
349 | repo.git.tag(bri_tag, sha1) | |
350 | ||
351 | def ver_to_release(): | |
352 | return {'v9.2': 'infernalis', 'v10.2': 'jewel', 'v11.2': 'kraken', | |
353 | 'v12.2': 'luminous', 'v13.2': 'mimic', 'v14.2': 'nautilus'} | |
354 | ||
355 | def usage(): | |
356 | logging.error("Redmine credentials are required to perform this operation. " | |
357 | "Please provide either a Redmine key (via {}) " | |
358 | "or a Redmine username and password (via --user and --password). " | |
359 | "Optionally, one or more issue numbers can be given via positional " | |
360 | "argument(s). In the absence of positional arguments, the script " | |
361 | "will loop through all merge commits after the tag \"BRI-{release}\". " | |
362 | "If there is no such tag in the local branch, one will be created " | |
363 | "for you.".format(redmine_key_file) | |
364 | ) | |
365 | exit(-1) | |
366 | ||
367 | ||
368 | class Backport: | |
369 | ||
370 | def __init__(self, repo, merge_commit_string): | |
371 | ''' | |
372 | The merge commit string should look something like this: | |
373 | 27ff851953 Merge pull request #29678 from pdvian/wip-40948-nautilus | |
374 | ''' | |
375 | global browser_cmd | |
376 | global ceph_release | |
377 | global github_token | |
378 | global github_user | |
379 | self.repo = repo | |
380 | self.merge_commit_string = merge_commit_string | |
381 | # | |
382 | # split merge commit string on first space character | |
383 | merge_commit_sha1_short, self.merge_commit_description = merge_commit_string.split(' ', 1) | |
384 | # | |
385 | # merge commit SHA1 from merge commit string | |
386 | p = re.compile('\\S+') | |
387 | self.merge_commit_sha1_short = p.match(merge_commit_sha1_short).group() | |
388 | assert self.merge_commit_sha1_short == merge_commit_sha1_short, \ | |
389 | ("Failed to extract merge commit short SHA1 from merge commit string ->{}<-" | |
390 | .format(merge_commit_string) | |
391 | ) | |
392 | logging.debug("Short merge commit SHA1 is {}".format(self.merge_commit_sha1_short)) | |
393 | self.merge_commit_sha1 = self.repo.git.rev_list( | |
394 | '--max-count=1', | |
395 | self.merge_commit_sha1_short, | |
396 | ) | |
397 | logging.debug("Full merge commit SHA1 is {}".format(self.merge_commit_sha1)) | |
398 | self.merge_commit_gd = repo.git.describe('--match', 'v*', self.merge_commit_sha1) | |
399 | self.populate_base_version() | |
400 | self.populate_target_version() | |
401 | self.populate_github_url() | |
402 | # | |
403 | # GitHub PR description and merged status from GitHub | |
404 | curl_opt = "--silent" | |
405 | # if GitHub token was provided, use it to avoid throttling - | |
406 | if github_token and github_user: | |
407 | curl_opt = "-u {}:{} {}".format(github_user, github_token, curl_opt) | |
408 | cmd = ( | |
409 | "curl {} https://api.github.com/repos/ceph/ceph/pulls/{}" | |
410 | .format(curl_opt, self.github_pr_id) | |
411 | ) | |
412 | logging.debug("Running curl command ->{}<-".format(cmd)) | |
413 | json_str = os.popen(cmd).read() | |
414 | github_api_result = json.loads(json_str) | |
415 | if "title" in github_api_result and "body" in github_api_result: | |
416 | self.github_pr_title = github_api_result["title"] | |
417 | self.github_pr_desc = github_api_result["body"] | |
418 | else: | |
419 | logging.error("GitHub API unexpectedly returned: {}".format(github_api_result)) | |
420 | logging.info("Curl command was: {}".format(cmd)) | |
421 | sys.exit(-1) | |
422 | self.mogrify_github_pr_desc() | |
423 | self.github_pr_merged = github_api_result["merged"] | |
424 | if not no_browser: | |
425 | if browser_running(): | |
426 | os.system("{} {}".format(browser_cmd, self.github_url)) | |
427 | pr_title_trunc = self.github_pr_title | |
428 | if len(pr_title_trunc) > 60: | |
429 | pr_title_trunc = pr_title_trunc[0:50] + "|TRUNCATED" | |
430 | print('''\n\n================================================================= | |
431 | GitHub PR URL: {} | |
432 | GitHub PR title: {} | |
433 | Merge commit: {} ({}) | |
434 | Merged: {} | |
435 | Ceph version: base {}, target {}''' | |
436 | .format(self.github_url, pr_title_trunc, self.merge_commit_sha1, | |
437 | self.merge_commit_gd, self.github_pr_merged, self.base_version, | |
438 | self.target_version | |
439 | ) | |
440 | ) | |
441 | if no_browser or not browser_running(): | |
442 | print('''----------------------- PR DESCRIPTION -------------------------- | |
443 | {} | |
444 | -----------------------------------------------------------------'''.format(self.github_pr_desc)) | |
445 | assert self.github_pr_merged, "GitHub PR {} has not been merged!".format(self.github_pr_id) | |
446 | # | |
447 | # obtain backport tracker from GitHub PR description | |
448 | self.extract_backport_trackers_from_github_pr_desc() | |
449 | # | |
450 | for bt in self.backport_trackers: | |
451 | # does the Backport Tracker description link back to the GitHub PR? | |
452 | p = re.compile('http.?://github.com/ceph/ceph/pull/\\d+') | |
453 | bt.get_tracker_description() | |
454 | try: | |
455 | bt.github_url_from_tracker = p.search(bt.tracker_description).group() | |
456 | except AttributeError: | |
457 | pass | |
458 | if bt.github_url_from_tracker: | |
459 | p = re.compile('\\d+') | |
460 | bt.github_id_from_tracker = p.search(bt.github_url_from_tracker).group() | |
461 | logging.debug("GitHub PR from Tracker: URL is ->{}<- and ID is {}" | |
462 | .format(bt.github_url_from_tracker, bt.github_id_from_tracker)) | |
463 | assert bt.github_id_from_tracker == self.github_pr_id, \ | |
464 | "GitHub PR ID {} does not match GitHub ID from tracker {}".format( | |
465 | self.github_pr_id, | |
466 | bt.github_id_from_tracker, | |
467 | ) | |
468 | print_inner_divider() | |
469 | if bt.github_url_from_tracker: | |
470 | logging.info("Tracker {} links to PR {}".format(bt.issue_url(), self.github_url)) | |
471 | else: | |
472 | logging.warning("Backport Tracker {} does not link to PR - will update" | |
473 | .format(bt.issue_id)) | |
474 | # | |
475 | # does the Backport Tracker's release field match the Ceph release? | |
476 | tracker_release = get_issue_release(bt.redmine_issue) | |
477 | assert ceph_release == tracker_release, \ | |
478 | ( | |
479 | "Backport Tracker {} is a {} backport - expected {}" | |
480 | .format(bt.issue_id, tracker_release, ceph_release) | |
481 | ) | |
482 | # | |
483 | # is the Backport Tracker's "Target version" custom field populated? | |
484 | try: | |
485 | ttv = bt.get_tracker_target_version() | |
486 | except: | |
487 | logging.info("Backport Tracker {} target version not populated yet!" | |
488 | .format(bt.issue_id)) | |
489 | bt.set_target_version = True | |
490 | else: | |
491 | bt.tracker_target_version = ttv | |
492 | logging.info("Backport Tracker {} target version already populated " | |
493 | "with correct value {}" | |
494 | .format(bt.issue_id, bt.tracker_target_version)) | |
495 | bt.set_target_version = False | |
496 | assert bt.tracker_target_version == self.target_version, \ | |
497 | ( | |
498 | "Tracker target version {} is wrong; should be {}" | |
499 | .format(bt.tracker_target_version, self.target_version) | |
500 | ) | |
501 | # | |
502 | # is the Backport Tracker's status already set to Resolved? | |
503 | resolved_id = status2status_id['Resolved'] | |
504 | if bt.redmine_issue.status.id == resolved_id: | |
505 | logging.info("Backport Tracker {} status is already set to Resolved" | |
506 | .format(bt.issue_id)) | |
507 | bt.set_tracker_status = False | |
508 | else: | |
509 | logging.info("Backport Tracker {} status is currently set to {}" | |
510 | .format(bt.issue_id, bt.redmine_issue.status)) | |
511 | bt.set_tracker_status = True | |
512 | print_outer_divider() | |
513 | ||
514 | def populate_base_version(self): | |
515 | self.base_version = ceph_version(self.repo, self.merge_commit_sha1) | |
516 | ||
517 | def populate_target_version(self): | |
518 | x, y, z = self.base_version.split('v')[1].split('.') | |
519 | maybe_stable = "v{}.{}".format(x, y) | |
520 | assert ver_to_release()[maybe_stable], \ | |
521 | "SHA1 {} is not based on any known stable release ({})".format(sha1, maybe_stable) | |
522 | tv = "v{}.{}.{}".format(x, y, int(z) + 1) | |
523 | if tv in version2version_id: | |
524 | self.target_version = tv | |
525 | else: | |
526 | raise Exception("Version {} not found in Redmine".format(tv)) | |
527 | ||
528 | def mogrify_github_pr_desc(self): | |
529 | if not self.github_pr_desc: | |
530 | self.github_pr_desc = '' | |
531 | p = re.compile('<!--.+-->', re.DOTALL) | |
532 | new_str = p.sub('', self.github_pr_desc) | |
533 | if new_str == self.github_pr_desc: | |
534 | logging.debug("GitHub PR description not mogrified") | |
535 | else: | |
536 | self.github_pr_desc = new_str | |
537 | ||
538 | def populate_github_url(self): | |
539 | global github_endpoint | |
540 | # GitHub PR ID from merge commit string | |
541 | p = re.compile('(pull request|PR) #(\\d+)') | |
542 | try: | |
543 | self.github_pr_id = p.search(self.merge_commit_description).group(2) | |
544 | except AttributeError: | |
545 | assert False, \ | |
546 | ( | |
547 | "Failed to extract GitHub PR ID from merge commit string ->{}<-" | |
548 | .format(self.merge_commit_string) | |
549 | ) | |
550 | logging.debug("Merge commit string: {}".format(self.merge_commit_string)) | |
551 | logging.debug("GitHub PR ID from merge commit string: {}".format(self.github_pr_id)) | |
552 | self.github_url = "{}/pull/{}".format(github_endpoint, self.github_pr_id) | |
553 | ||
554 | def extract_backport_trackers_from_github_pr_desc(self): | |
555 | global redmine_endpoint | |
556 | p = re.compile('http.?://tracker.ceph.com/issues/\\d+') | |
557 | matching_strings = p.findall(self.github_pr_desc) | |
558 | if not matching_strings: | |
559 | print_outer_divider() | |
560 | assert False, \ | |
561 | "GitHub PR description does not contain a Tracker URL" | |
562 | self.backport_trackers = [] | |
563 | for issue_url in list(dict.fromkeys(matching_strings)): | |
564 | p = re.compile('\\d+') | |
565 | issue_id = p.search(issue_url).group() | |
566 | if not issue_id: | |
567 | print_outer_divider() | |
568 | assert issue_id, \ | |
569 | "Failed to extract tracker ID from tracker URL {}".format(issue_url) | |
570 | issue_url = "{}/issues/{}".format(redmine_endpoint, issue_id) | |
571 | # | |
572 | # we have a Tracker URL, but is it really a backport tracker? | |
573 | backport_tracker_id = tracker2tracker_id['Backport'] | |
574 | redmine_issue = redmine.issue.get(issue_id) | |
575 | if redmine_issue.tracker.id == backport_tracker_id: | |
576 | self.backport_trackers.append( | |
577 | BackportTracker(redmine_issue, issue_id, self) | |
578 | ) | |
579 | print('''Found backport tracker: {}'''.format(issue_url)) | |
580 | if not self.backport_trackers: | |
581 | print_outer_divider() | |
582 | assert False, \ | |
583 | "No backport tracker found in PR description at {}".format(self.github_url) | |
584 | ||
585 | def resolve(self): | |
586 | for bt in self.backport_trackers: | |
587 | bt.resolve() | |
588 | ||
589 | ||
590 | class BackportTracker(Backport): | |
591 | ||
592 | def __init__(self, redmine_issue, issue_id, backport_obj): | |
593 | self.redmine_issue = redmine_issue | |
594 | self.issue_id = issue_id | |
595 | self.parent = backport_obj | |
596 | self.tracker_description = None | |
597 | self.github_url_from_tracker = None | |
598 | ||
599 | def get_tracker_description(self): | |
600 | try: | |
601 | self.tracker_description = self.redmine_issue.description | |
602 | except ResourceAttrError: | |
603 | self.tracker_description = "" | |
604 | ||
605 | def get_tracker_target_version(self): | |
606 | if self.redmine_issue.fixed_version: | |
607 | logging.debug("Target version: ID {}, name {}" | |
608 | .format( | |
609 | self.redmine_issue.fixed_version.id, | |
610 | self.redmine_issue.fixed_version.name | |
611 | ) | |
612 | ) | |
613 | return self.redmine_issue.fixed_version.name | |
614 | return None | |
615 | ||
616 | def issue_url(self): | |
617 | return "{}/issues/{}".format(redmine_endpoint, self.issue_id) | |
618 | ||
619 | def resolve(self): | |
620 | global delay_seconds | |
621 | global dry_run | |
622 | global redmine | |
623 | kwargs = {} | |
624 | if self.set_tracker_status: | |
625 | kwargs['status_id'] = status2status_id['Resolved'] | |
626 | if self.set_target_version: | |
627 | kwargs['fixed_version_id'] = version2version_id[self.parent.target_version] | |
628 | if not self.github_url_from_tracker: | |
629 | if self.tracker_description: | |
630 | kwargs['description'] = "{}\n\n---\n\n{}".format( | |
631 | self.parent.github_url, | |
632 | self.tracker_description, | |
633 | ) | |
634 | else: | |
635 | kwargs['description'] = self.parent.github_url | |
636 | kwargs['notes'] = ( | |
637 | "This update was made using the script \"backport-resolve-issue\".\n" | |
638 | "backport PR {}\n" | |
639 | "merge commit {} ({})\n".format( | |
640 | self.parent.github_url, | |
641 | self.parent.merge_commit_sha1, | |
642 | self.parent.merge_commit_gd, | |
643 | ) | |
644 | ) | |
645 | my_delay_seconds = delay_seconds | |
646 | if dry_run: | |
647 | logging.info("--dry-run was given: NOT updating Redmine") | |
648 | my_delay_seconds = 0 | |
649 | else: | |
650 | logging.debug("Updating tracker ID {}".format(self.issue_id)) | |
651 | redmine.issue.update(self.issue_id, **kwargs) | |
652 | if not no_browser: | |
653 | if browser_running(): | |
654 | os.system("{} {}".format(browser_cmd, self.issue_url())) | |
655 | my_delay_seconds = 3 | |
656 | logging.debug( | |
657 | "Delaying {} seconds to avoid seeming like a spammer" | |
658 | .format(my_delay_seconds) | |
659 | ) | |
660 | time.sleep(my_delay_seconds) | |
661 | ||
662 | ||
663 | if __name__ == '__main__': | |
664 | args = parse_arguments() | |
665 | set_logging_level(args) | |
666 | logging.debug(args) | |
667 | github_token = read_from_file(github_token_file) | |
668 | if github_token: | |
669 | logging.info("GitHub token was read from ->{}<-; using it".format(github_token_file)) | |
670 | github_user = derive_github_user_from_token(github_token) | |
671 | if github_user: | |
672 | logging.info( | |
673 | "GitHub user ->{}<- was derived from the GitHub token".format(github_user) | |
674 | ) | |
675 | report_params(args) | |
676 | # | |
677 | # set up Redmine variables | |
678 | redmine = connect_to_redmine(args) | |
679 | project = redmine.project.get(project_name) | |
680 | ceph_project_id = project.id | |
681 | logging.debug("Project {} has ID {}".format(project_name, ceph_project_id)) | |
682 | populate_status_dict(redmine) | |
683 | pending_backport_status_id = status2status_id["Pending Backport"] | |
684 | logging.debug( | |
685 | "Pending Backport status has ID {}" | |
686 | .format(pending_backport_status_id) | |
687 | ) | |
688 | populate_tracker_dict(redmine) | |
689 | populate_version_dict(redmine, ceph_project_id) | |
690 | # | |
691 | # construct github Repo object for the current directory | |
692 | repo = Repo('.') | |
693 | assert not repo.bare | |
694 | populate_ceph_release(repo) | |
695 | # | |
696 | # if positional argument is an integer, assume it is a GitHub PR | |
697 | if args.pr_or_commit: | |
698 | pr_id = args.pr_or_commit[0] | |
699 | try: | |
700 | pr_id = int(pr_id) | |
701 | logging.info("Examining PR#{}".format(pr_id)) | |
702 | tag_merge_commits = False | |
703 | except ValueError: | |
704 | logging.info("Starting from merge commit {}".format(args.pr_or_commit)) | |
705 | tag_merge_commits = True | |
706 | else: | |
707 | logging.info("Starting from BRI tag") | |
708 | tag_merge_commits = True | |
709 | # | |
710 | # get list of merges | |
711 | if tag_merge_commits: | |
712 | ensure_bri_tag_exists(repo, ceph_release) | |
713 | c_r = commit_range(args) | |
714 | logging.info("Commit range is {}".format(c_r)) | |
715 | # | |
716 | # get the list of merge commits, i.e. strings that looks like: | |
717 | # "27ff851953 Merge pull request #29678 from pdvian/wip-40948-nautilus" | |
718 | merges_raw_str = repo.git.log(c_r, '--merges', '--oneline', '--no-decorate', '--reverse') | |
719 | else: | |
720 | pr_id = args.pr_or_commit[0] | |
721 | merges_raw_str = repo.git.log( | |
722 | '--merges', | |
723 | '--grep=#{}'.format(pr_id), | |
724 | '--oneline', | |
725 | '--no-decorate', | |
726 | '--reverse', | |
727 | ) | |
728 | if merges_raw_str: | |
729 | merges_raw_list = merges_raw_str.split('\n') | |
730 | else: | |
731 | merges_raw_list = [] # prevent [''] | |
732 | merges_remaining = len(merges_raw_list) | |
733 | logging.info("I see {} merge(s) to process".format(merges_remaining)) | |
734 | if not merges_remaining: | |
735 | logging.info("Did you do \"git pull\" before running the script?") | |
736 | if not tag_merge_commits: | |
737 | logging.info("Or maybe GitHub PR {} has not been merged yet?".format(pr_id)) | |
738 | # | |
739 | # loop over the merge commits | |
740 | for merge in merges_raw_list: | |
741 | can_go_on = process_merge(repo, merge, merges_remaining) | |
742 | if can_go_on: | |
743 | merges_remaining -= 1 | |
744 | print("Merges remaining to process: {}".format(merges_remaining)) | |
745 | else: | |
746 | break |