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 gitutil 16from patman import settings 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 aliases/emails to Cc all patches to 29 commits: List of Commit objects, one for each patch 30 cover: List of lines in the cover letter 31 notes: List of lines in the notes 32 changes: (dict) List of changes for each version, The key is 33 the integer version number 34 allow_overwrite: Allow tags to overwrite an existing tag 35 """ 36 def __init__(self): 37 self.cc = [] 38 self.to = [] 39 self.cover_cc = [] 40 self.commits = [] 41 self.cover = None 42 self.notes = [] 43 self.changes = {} 44 self.allow_overwrite = False 45 46 # Written in MakeCcFile() 47 # key: name of patch file 48 # value: list of email addresses 49 self._generated_cc = {} 50 51 # These make us more like a dictionary 52 def __setattr__(self, name, value): 53 self[name] = value 54 55 def __getattr__(self, name): 56 return self[name] 57 58 def AddTag(self, commit, line, name, value): 59 """Add a new Series-xxx tag along with its value. 60 61 Args: 62 line: Source line containing tag (useful for debug/error messages) 63 name: Tag name (part after 'Series-') 64 value: Tag value (part after 'Series-xxx: ') 65 66 Returns: 67 String warning if something went wrong, else None 68 """ 69 # If we already have it, then add to our list 70 name = name.replace('-', '_') 71 if name in self and not self.allow_overwrite: 72 values = value.split(',') 73 values = [str.strip() for str in values] 74 if type(self[name]) != type([]): 75 raise ValueError("In %s: line '%s': Cannot add another value " 76 "'%s' to series '%s'" % 77 (commit.hash, line, values, self[name])) 78 self[name] += values 79 80 # Otherwise just set the value 81 elif name in valid_series: 82 if name=="notes": 83 self[name] = [value] 84 else: 85 self[name] = value 86 else: 87 return ("In %s: line '%s': Unknown 'Series-%s': valid " 88 "options are %s" % (commit.hash, line, name, 89 ', '.join(valid_series))) 90 return None 91 92 def AddCommit(self, commit): 93 """Add a commit into our list of commits 94 95 We create a list of tags in the commit subject also. 96 97 Args: 98 commit: Commit object to add 99 """ 100 commit.check_tags() 101 self.commits.append(commit) 102 103 def ShowActions(self, args, cmd, process_tags): 104 """Show what actions we will/would perform 105 106 Args: 107 args: List of patch files we created 108 cmd: The git command we would have run 109 process_tags: Process tags as if they were aliases 110 """ 111 to_set = set(gitutil.build_email_list(self.to)); 112 cc_set = set(gitutil.build_email_list(self.cc)); 113 114 col = terminal.Color() 115 print('Dry run, so not doing much. But I would do this:') 116 print() 117 print('Send a total of %d patch%s with %scover letter.' % ( 118 len(args), '' if len(args) == 1 else 'es', 119 self.get('cover') and 'a ' or 'no ')) 120 121 # TODO: Colour the patches according to whether they passed checks 122 for upto in range(len(args)): 123 commit = self.commits[upto] 124 print(col.build(col.GREEN, ' %s' % args[upto])) 125 cc_list = list(self._generated_cc[commit.patch]) 126 for email in sorted(set(cc_list) - to_set - cc_set): 127 if email == None: 128 email = col.build(col.YELLOW, '<alias not found>') 129 if email: 130 print(' Cc: ', email) 131 print 132 for item in sorted(to_set): 133 print('To:\t ', item) 134 for item in sorted(cc_set - to_set): 135 print('Cc:\t ', item) 136 print('Version: ', self.get('version')) 137 print('Prefix:\t ', self.get('prefix')) 138 print('Postfix:\t ', self.get('postfix')) 139 if self.cover: 140 print('Cover: %d lines' % len(self.cover)) 141 cover_cc = gitutil.build_email_list(self.get('cover_cc', '')) 142 all_ccs = itertools.chain(cover_cc, *self._generated_cc.values()) 143 for email in sorted(set(all_ccs) - to_set - cc_set): 144 print(' Cc: ', email) 145 if cmd: 146 print('Git command: %s' % cmd) 147 148 def MakeChangeLog(self, commit): 149 """Create a list of changes for each version. 150 151 Return: 152 The change log as a list of strings, one per line 153 154 Changes in v4: 155 - Jog the dial back closer to the widget 156 157 Changes in v2: 158 - Fix the widget 159 - Jog the dial 160 161 If there are no new changes in a patch, a note will be added 162 163 (no changes since v2) 164 165 Changes in v2: 166 - Fix the widget 167 - Jog the dial 168 """ 169 # Collect changes from the series and this commit 170 changes = collections.defaultdict(list) 171 for version, changelist in self.changes.items(): 172 changes[version] += changelist 173 if commit: 174 for version, changelist in commit.changes.items(): 175 changes[version] += [[commit, text] for text in changelist] 176 177 versions = sorted(changes, reverse=True) 178 newest_version = 1 179 if 'version' in self: 180 newest_version = max(newest_version, int(self.version)) 181 if versions: 182 newest_version = max(newest_version, versions[0]) 183 184 final = [] 185 process_it = self.get('process_log', '').split(',') 186 process_it = [item.strip() for item in process_it] 187 need_blank = False 188 for version in versions: 189 out = [] 190 for this_commit, text in changes[version]: 191 if commit and this_commit != commit: 192 continue 193 if 'uniq' not in process_it or text not in out: 194 out.append(text) 195 if 'sort' in process_it: 196 out = sorted(out) 197 have_changes = len(out) > 0 198 line = 'Changes in v%d:' % version 199 if have_changes: 200 out.insert(0, line) 201 if version < newest_version and len(final) == 0: 202 out.insert(0, '') 203 out.insert(0, '(no changes since v%d)' % version) 204 newest_version = 0 205 # Only add a new line if we output something 206 if need_blank: 207 out.insert(0, '') 208 need_blank = False 209 final += out 210 need_blank = need_blank or have_changes 211 212 if len(final) > 0: 213 final.append('') 214 elif newest_version != 1: 215 final = ['(no changes since v1)', ''] 216 return final 217 218 def DoChecks(self): 219 """Check that each version has a change log 220 221 Print an error if something is wrong. 222 """ 223 col = terminal.Color() 224 if self.get('version'): 225 changes_copy = dict(self.changes) 226 for version in range(1, int(self.version) + 1): 227 if self.changes.get(version): 228 del changes_copy[version] 229 else: 230 if version > 1: 231 str = 'Change log missing for v%d' % version 232 print(col.build(col.RED, str)) 233 for version in changes_copy: 234 str = 'Change log for unknown version v%d' % version 235 print(col.build(col.RED, str)) 236 elif self.changes: 237 str = 'Change log exists, but no version is set' 238 print(col.build(col.RED, str)) 239 240 def GetCcForCommit(self, commit, process_tags, warn_on_error, 241 add_maintainers, limit, get_maintainer_script, 242 all_skips): 243 """Get the email CCs to use with a particular commit 244 245 Uses subject tags and get_maintainers.pl script to find people to cc 246 on a patch 247 248 Args: 249 commit (Commit): Commit to process 250 process_tags (bool): Process tags as if they were aliases 251 warn_on_error (bool): True to print a warning when an alias fails to 252 match, False to ignore it. 253 add_maintainers (bool or list of str): Either: 254 True/False to call the get_maintainers to CC maintainers 255 List of maintainers to include (for testing) 256 limit (int): Limit the length of the Cc list (None if no limit) 257 get_maintainer_script (str): The file name of the get_maintainer.pl 258 script (or compatible). 259 all_skips (set of str): Updated to include the set of bouncing email 260 addresses that were dropped from the output. This is essentially 261 a return value from this function. 262 263 Returns: 264 list of str: List of email addresses to cc 265 """ 266 cc = [] 267 if process_tags: 268 cc += gitutil.build_email_list(commit.tags, 269 warn_on_error=warn_on_error) 270 cc += gitutil.build_email_list(commit.cc_list, 271 warn_on_error=warn_on_error) 272 if type(add_maintainers) == type(cc): 273 cc += add_maintainers 274 elif add_maintainers: 275 cc += get_maintainer.get_maintainer(get_maintainer_script, 276 commit.patch) 277 all_skips |= set(cc) & set(settings.bounces) 278 cc = list(set(cc) - set(settings.bounces)) 279 if limit is not None: 280 cc = cc[:limit] 281 return cc 282 283 def MakeCcFile(self, process_tags, cover_fname, warn_on_error, 284 add_maintainers, limit, get_maintainer_script): 285 """Make a cc file for us to use for per-commit Cc automation 286 287 Also stores in self._generated_cc to make ShowActions() faster. 288 289 Args: 290 process_tags (bool): Process tags as if they were aliases 291 cover_fname (str): If non-None the name of the cover letter. 292 warn_on_error (bool): True to print a warning when an alias fails to 293 match, False to ignore it. 294 add_maintainers (bool or list of str): Either: 295 True/False to call the get_maintainers to CC maintainers 296 List of maintainers to include (for testing) 297 limit (int): Limit the length of the Cc list (None if no limit) 298 get_maintainer_script (str): The file name of the get_maintainer.pl 299 script (or compatible). 300 Return: 301 Filename of temp file created 302 """ 303 col = terminal.Color() 304 # Look for commit tags (of the form 'xxx:' at the start of the subject) 305 fname = '/tmp/patman.%d' % os.getpid() 306 fd = open(fname, 'w', encoding='utf-8') 307 all_ccs = [] 308 all_skips = set() 309 with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor: 310 for i, commit in enumerate(self.commits): 311 commit.seq = i 312 commit.future = executor.submit( 313 self.GetCcForCommit, commit, process_tags, warn_on_error, 314 add_maintainers, limit, get_maintainer_script, all_skips) 315 316 # Show progress any commits that are taking forever 317 lastlen = 0 318 while True: 319 left = [commit for commit in self.commits 320 if not commit.future.done()] 321 if not left: 322 break 323 names = ', '.join(f'{c.seq + 1}:{c.subject}' 324 for c in left[:2]) 325 out = f'\r{len(left)} remaining: {names}'[:79] 326 spaces = ' ' * (lastlen - len(out)) 327 if lastlen: # Don't print anything the first time 328 print(out, spaces, end='') 329 sys.stdout.flush() 330 lastlen = len(out) 331 time.sleep(.25) 332 print(f'\rdone{" " * lastlen}\r', end='') 333 print('Cc processing complete') 334 335 for commit in self.commits: 336 cc = commit.future.result() 337 all_ccs += cc 338 print(commit.patch, '\0'.join(sorted(set(cc))), file=fd) 339 self._generated_cc[commit.patch] = cc 340 341 for x in sorted(all_skips): 342 print(col.build(col.YELLOW, f'Skipping "{x}"')) 343 344 if cover_fname: 345 cover_cc = gitutil.build_email_list(self.get('cover_cc', '')) 346 cover_cc = list(set(cover_cc + all_ccs)) 347 if limit is not None: 348 cover_cc = cover_cc[:limit] 349 cc_list = '\0'.join([x for x in sorted(cover_cc)]) 350 print(cover_fname, cc_list, file=fd) 351 352 fd.close() 353 return fname 354 355 def AddChange(self, version, commit, info): 356 """Add a new change line to a version. 357 358 This will later appear in the change log. 359 360 Args: 361 version: version number to add change list to 362 info: change line for this version 363 """ 364 if not self.changes.get(version): 365 self.changes[version] = [] 366 self.changes[version].append([commit, info]) 367 368 def GetPatchPrefix(self): 369 """Get the patch version string 370 371 Return: 372 Patch string, like 'RFC PATCH v5' or just 'PATCH' 373 """ 374 git_prefix = gitutil.get_default_subject_prefix() 375 if git_prefix: 376 git_prefix = '%s][' % git_prefix 377 else: 378 git_prefix = '' 379 380 version = '' 381 if self.get('version'): 382 version = ' v%s' % self['version'] 383 384 # Get patch name prefix 385 prefix = '' 386 if self.get('prefix'): 387 prefix = '%s ' % self['prefix'] 388 389 postfix = '' 390 if self.get('postfix'): 391 postfix = ' %s' % self['postfix'] 392 return '%s%sPATCH%s%s' % (git_prefix, prefix, postfix, version) 393