1# SPDX-License-Identifier: GPL-2.0+ 2# Copyright (c) 2011 The Chromium OS Authors. 3# 4 5from __future__ import print_function 6 7import collections 8import concurrent.futures 9import itertools 10import os 11import sys 12import time 13 14from patman import get_maintainer 15from patman import settings 16from u_boot_pylib import gitutil 17from u_boot_pylib import terminal 18from u_boot_pylib import tools 19 20# Series-xxx tags that we understand 21valid_series = ['to', 'cc', 'version', 'changes', 'prefix', 'notes', 'name', 22 'cover_cc', 'process_log', 'links', 'patchwork_url', 'postfix'] 23 24class Series(dict): 25 """Holds information about a patch series, including all tags. 26 27 Vars: 28 cc (list of str): Aliases/emails to Cc all patches to 29 to (list of str): Aliases/emails to send patches to 30 commits (list of Commit): Commit objects, one for each patch 31 cover (list of str): Lines in the cover letter 32 notes (list of str): Lines in the notes 33 changes: (dict) List of changes for each version: 34 key (int): version number 35 value: tuple: 36 commit (Commit): Commit this relates to, or None if related to a 37 cover letter 38 info (str): change lines for this version (separated by \n) 39 allow_overwrite (bool): Allow tags to overwrite an existing tag 40 base_commit (Commit): Commit object at the base of this series 41 branch (str): Branch name of this series 42 desc (str): Description of the series (cover-letter title) 43 idnum (int or None): Database rowid 44 name (str): Series name, typically the branch name without any numeric 45 suffix 46 _generated_cc (dict) written in MakeCcFile() 47 key: name of patch file 48 value: list of email addresses 49 """ 50 def __init__(self): 51 self.cc = [] 52 self.to = [] 53 self.cover_cc = [] 54 self.commits = [] 55 self.cover = None 56 self.notes = [] 57 self.changes = {} 58 self.allow_overwrite = False 59 self.base_commit = None 60 self.branch = None 61 self.desc = '' 62 self.idnum = None 63 self.name = None 64 self._generated_cc = {} 65 66 # These make us more like a dictionary 67 def __setattr__(self, name, value): 68 self[name] = value 69 70 def __getattr__(self, name): 71 return self[name] 72 73 @staticmethod 74 def from_fields(idnum, name, desc): 75 ser = Series() 76 ser.idnum = idnum 77 ser.name = name 78 ser.desc = desc 79 return ser 80 81 def AddTag(self, commit, line, name, value): 82 """Add a new Series-xxx tag along with its value. 83 84 Args: 85 line: Source line containing tag (useful for debug/error messages) 86 name: Tag name (part after 'Series-') 87 value: Tag value (part after 'Series-xxx: ') 88 89 Returns: 90 String warning if something went wrong, else None 91 """ 92 # If we already have it, then add to our list 93 name = name.replace('-', '_') 94 if name in self and not self.allow_overwrite: 95 values = value.split(',') 96 values = [str.strip() for str in values] 97 if type(self[name]) != type([]): 98 raise ValueError("In %s: line '%s': Cannot add another value " 99 "'%s' to series '%s'" % 100 (commit.hash, line, values, self[name])) 101 self[name] += values 102 103 # Otherwise just set the value 104 elif name in valid_series: 105 if name=="notes": 106 self[name] = [value] 107 else: 108 self[name] = value 109 else: 110 return ("In %s: line '%s': Unknown 'Series-%s': valid " 111 "options are %s" % (commit.hash, line, name, 112 ', '.join(valid_series))) 113 return None 114 115 def AddCommit(self, commit): 116 """Add a commit into our list of commits 117 118 We create a list of tags in the commit subject also. 119 120 Args: 121 commit: Commit object to add 122 """ 123 commit.check_tags() 124 self.commits.append(commit) 125 126 def ShowActions(self, args, cmd, process_tags, alias): 127 """Show what actions we will/would perform 128 129 Args: 130 args: List of patch files we created 131 cmd: The git command we would have run 132 process_tags: Process tags as if they were aliases 133 alias (dict): Alias dictionary 134 key: alias 135 value: list of aliases or email addresses 136 """ 137 to_set = set(gitutil.build_email_list(self.to, alias)); 138 cc_set = set(gitutil.build_email_list(self.cc, alias)); 139 140 col = terminal.Color() 141 print('Dry run, so not doing much. But I would do this:') 142 print() 143 print('Send a total of %d patch%s with %scover letter.' % ( 144 len(args), '' if len(args) == 1 else 'es', 145 self.get('cover') and 'a ' or 'no ')) 146 147 # TODO: Colour the patches according to whether they passed checks 148 for upto in range(len(args)): 149 commit = self.commits[upto] 150 print(col.build(col.GREEN, ' %s' % args[upto])) 151 cc_list = list(self._generated_cc[commit.patch]) 152 for email in sorted(set(cc_list) - to_set - cc_set): 153 if email == None: 154 email = col.build(col.YELLOW, '<alias not found>') 155 if email: 156 print(' Cc: ', email) 157 print 158 for item in sorted(to_set): 159 print('To:\t ', item) 160 for item in sorted(cc_set - to_set): 161 print('Cc:\t ', item) 162 print('Version: ', self.get('version')) 163 print('Prefix:\t ', self.get('prefix')) 164 print('Postfix:\t ', self.get('postfix')) 165 if self.cover: 166 print('Cover: %d lines' % len(self.cover)) 167 cover_cc = gitutil.build_email_list(self.get('cover_cc', ''), 168 alias) 169 all_ccs = itertools.chain(cover_cc, *self._generated_cc.values()) 170 for email in sorted(set(all_ccs) - to_set - cc_set): 171 print(' Cc: ', email) 172 if cmd: 173 print('Git command: %s' % cmd) 174 175 def MakeChangeLog(self, commit): 176 """Create a list of changes for each version. 177 178 Return: 179 The change log as a list of strings, one per line 180 181 Changes in v4: 182 - Jog the dial back closer to the widget 183 184 Changes in v2: 185 - Fix the widget 186 - Jog the dial 187 188 If there are no new changes in a patch, a note will be added 189 190 (no changes since v2) 191 192 Changes in v2: 193 - Fix the widget 194 - Jog the dial 195 """ 196 # Collect changes from the series and this commit 197 changes = collections.defaultdict(list) 198 for version, changelist in self.changes.items(): 199 changes[version] += changelist 200 if commit: 201 for version, changelist in commit.changes.items(): 202 changes[version] += [[commit, text] for text in changelist] 203 204 versions = sorted(changes, reverse=True) 205 newest_version = 1 206 if 'version' in self: 207 newest_version = max(newest_version, int(self.version)) 208 if versions: 209 newest_version = max(newest_version, versions[0]) 210 211 final = [] 212 process_it = self.get('process_log', '').split(',') 213 process_it = [item.strip() for item in process_it] 214 need_blank = False 215 for version in versions: 216 out = [] 217 for this_commit, text in changes[version]: 218 if commit and this_commit != commit: 219 continue 220 if 'uniq' not in process_it or text not in out: 221 out.append(text) 222 if 'sort' in process_it: 223 out = sorted(out) 224 have_changes = len(out) > 0 225 line = 'Changes in v%d:' % version 226 if have_changes: 227 out.insert(0, line) 228 if version < newest_version and len(final) == 0: 229 out.insert(0, '') 230 out.insert(0, '(no changes since v%d)' % version) 231 newest_version = 0 232 # Only add a new line if we output something 233 if need_blank: 234 out.insert(0, '') 235 need_blank = False 236 final += out 237 need_blank = need_blank or have_changes 238 239 if len(final) > 0: 240 final.append('') 241 elif newest_version != 1: 242 final = ['(no changes since v1)', ''] 243 return final 244 245 def DoChecks(self): 246 """Check that each version has a change log 247 248 Print an error if something is wrong. 249 """ 250 col = terminal.Color() 251 if self.get('version'): 252 changes_copy = dict(self.changes) 253 for version in range(1, int(self.version) + 1): 254 if self.changes.get(version): 255 del changes_copy[version] 256 else: 257 if version > 1: 258 str = 'Change log missing for v%d' % version 259 print(col.build(col.RED, str)) 260 for version in changes_copy: 261 str = 'Change log for unknown version v%d' % version 262 print(col.build(col.RED, str)) 263 elif self.changes: 264 str = 'Change log exists, but no version is set' 265 print(col.build(col.RED, str)) 266 267 def GetCcForCommit(self, commit, process_tags, warn_on_error, 268 add_maintainers, limit, get_maintainer_script, 269 all_skips, alias, cwd): 270 """Get the email CCs to use with a particular commit 271 272 Uses subject tags and get_maintainers.pl script to find people to cc 273 on a patch 274 275 Args: 276 commit (Commit): Commit to process 277 process_tags (bool): Process tags as if they were aliases 278 warn_on_error (bool): True to print a warning when an alias fails to 279 match, False to ignore it. 280 add_maintainers (bool or list of str): Either: 281 True/False to call the get_maintainers to CC maintainers 282 List of maintainers to include (for testing) 283 limit (int): Limit the length of the Cc list (None if no limit) 284 get_maintainer_script (str): The file name of the get_maintainer.pl 285 script (or compatible). 286 all_skips (set of str): Updated to include the set of bouncing email 287 addresses that were dropped from the output. This is essentially 288 a return value from this function. 289 alias (dict): Alias dictionary 290 key: alias 291 value: list of aliases or email addresses 292 cwd (str): Path to use for patch filenames (None to use current dir) 293 294 Returns: 295 list of str: List of email addresses to cc 296 """ 297 cc = [] 298 if process_tags: 299 cc += gitutil.build_email_list(commit.tags, alias, 300 warn_on_error=warn_on_error) 301 cc += gitutil.build_email_list(commit.cc_list, alias, 302 warn_on_error=warn_on_error) 303 if type(add_maintainers) == type(cc): 304 cc += add_maintainers 305 elif add_maintainers: 306 fname = os.path.join(cwd or '', commit.patch) 307 cc += get_maintainer.get_maintainer(get_maintainer_script, fname) 308 all_skips |= set(cc) & set(settings.bounces) 309 cc = list(set(cc) - set(settings.bounces)) 310 if limit is not None: 311 cc = cc[:limit] 312 return cc 313 314 def MakeCcFile(self, process_tags, cover_fname, warn_on_error, 315 add_maintainers, limit, get_maintainer_script, alias, 316 cwd=None): 317 """Make a cc file for us to use for per-commit Cc automation 318 319 Also stores in self._generated_cc to make ShowActions() faster. 320 321 Args: 322 process_tags (bool): Process tags as if they were aliases 323 cover_fname (str): If non-None the name of the cover letter. 324 warn_on_error (bool): True to print a warning when an alias fails to 325 match, False to ignore it. 326 add_maintainers (bool or list of str): Either: 327 True/False to call the get_maintainers to CC maintainers 328 List of maintainers to include (for testing) 329 limit (int): Limit the length of the Cc list (None if no limit) 330 get_maintainer_script (str): The file name of the get_maintainer.pl 331 script (or compatible). 332 alias (dict): Alias dictionary 333 key: alias 334 value: list of aliases or email addresses 335 cwd (str): Path to use for patch filenames (None to use current dir) 336 Return: 337 Filename of temp file created 338 """ 339 col = terminal.Color() 340 # Look for commit tags (of the form 'xxx:' at the start of the subject) 341 fname = '/tmp/patman.%d' % os.getpid() 342 fd = open(fname, 'w', encoding='utf-8') 343 all_ccs = [] 344 all_skips = set() 345 with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor: 346 for i, commit in enumerate(self.commits): 347 commit.seq = i 348 commit.future = executor.submit( 349 self.GetCcForCommit, commit, process_tags, warn_on_error, 350 add_maintainers, limit, get_maintainer_script, all_skips, 351 alias, cwd) 352 353 # Show progress any commits that are taking forever 354 lastlen = 0 355 while True: 356 left = [commit for commit in self.commits 357 if not commit.future.done()] 358 if not left: 359 break 360 names = ', '.join(f'{c.seq + 1}:{c.subject}' 361 for c in left[:2]) 362 out = f'\r{len(left)} remaining: {names}'[:79] 363 spaces = ' ' * (lastlen - len(out)) 364 if lastlen: # Don't print anything the first time 365 print(out, spaces, end='') 366 sys.stdout.flush() 367 lastlen = len(out) 368 time.sleep(.25) 369 print(f'\rdone{" " * lastlen}\r', end='') 370 print('Cc processing complete') 371 372 for commit in self.commits: 373 cc = commit.future.result() 374 all_ccs += cc 375 print(commit.patch, '\0'.join(sorted(set(cc))), file=fd) 376 self._generated_cc[commit.patch] = cc 377 378 for x in sorted(all_skips): 379 print(col.build(col.YELLOW, f'Skipping "{x}"')) 380 381 if cover_fname: 382 cover_cc = gitutil.build_email_list( 383 self.get('cover_cc', ''), alias) 384 cover_cc = list(set(cover_cc + all_ccs)) 385 if limit is not None: 386 cover_cc = cover_cc[:limit] 387 cc_list = '\0'.join([x for x in sorted(cover_cc)]) 388 print(cover_fname, cc_list, file=fd) 389 390 fd.close() 391 return fname 392 393 def AddChange(self, version, commit, info): 394 """Add a new change line to a version. 395 396 This will later appear in the change log. 397 398 Args: 399 version (int): version number to add change list to 400 commit (Commit): Commit this relates to, or None if related to a 401 cover letter 402 info (str): change lines for this version (separated by \n) 403 """ 404 if not self.changes.get(version): 405 self.changes[version] = [] 406 self.changes[version].append([commit, info]) 407 408 def GetPatchPrefix(self): 409 """Get the patch version string 410 411 Return: 412 Patch string, like 'RFC PATCH v5' or just 'PATCH' 413 """ 414 git_prefix = gitutil.get_default_subject_prefix() 415 if git_prefix: 416 git_prefix = '%s][' % git_prefix 417 else: 418 git_prefix = '' 419 420 version = '' 421 if self.get('version'): 422 version = ' v%s' % self['version'] 423 424 # Get patch name prefix 425 prefix = '' 426 if self.get('prefix'): 427 prefix = '%s ' % self['prefix'] 428 429 postfix = '' 430 if self.get('postfix'): 431 postfix = ' %s' % self['postfix'] 432 return '%s%sPATCH%s%s' % (git_prefix, prefix, postfix, version) 433 434 def get_links(self, links_str=None, cur_version=None): 435 """Look up the patchwork links for each version 436 437 Args: 438 links_str (str): Links string to parse, or None to use self.links 439 cur_version (int): Default version to assume for un-versioned links, 440 or None to use self.version 441 442 Return: 443 dict: 444 key (int): Version number 445 value (str): Link string 446 """ 447 if links_str is None: 448 links_str = self.links if 'links' in self else '' 449 if cur_version is None: 450 cur_version = int(self.version) if 'version' in self else 1 451 assert isinstance(cur_version, int) 452 links = {} 453 for item in links_str.split(): 454 if ':' in item: 455 version, link = item.split(':') 456 links[int(version)] = link 457 else: 458 links[cur_version] = item 459 return links 460 461 def build_links(self, links): 462 """Build a string containing the links 463 464 Args: 465 links (dict): 466 key (int): Version number 467 value (str): Link string 468 469 Return: 470 str: Link string, e.g. '2:4433 1:2872' 471 """ 472 out = '' 473 for vers in sorted(links.keys(), reverse=True): 474 out += f' {vers}:{links[vers]}' 475 return out[1:] 476 477 def get_link_for_version(self, find_vers, links_str=None): 478 """Look up the patchwork link for a particular version 479 480 Args: 481 find_vers (int): Version to find 482 links_str (str): Links string to parse, or None to use self.links 483 484 Return: 485 str: Series-links entry for that version, or None if not found 486 """ 487 return self.get_links(links_str).get(find_vers) 488