1#!/usr/bin/env python3 2# 3# This file is part of GCC. 4# 5# GCC is free software; you can redistribute it and/or modify it under 6# the terms of the GNU General Public License as published by the Free 7# Software Foundation; either version 3, or (at your option) any later 8# version. 9# 10# GCC is distributed in the hope that it will be useful, but WITHOUT ANY 11# WARRANTY; without even the implied warranty of MERCHANTABILITY or 12# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 13# for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with GCC; see the file COPYING3. If not see 17# <http://www.gnu.org/licenses/>. */ 18 19import difflib 20import os 21import re 22import sys 23 24default_changelog_locations = { 25 'c++tools', 26 'config', 27 'contrib', 28 'contrib/header-tools', 29 'contrib/reghunt', 30 'contrib/regression', 31 'fixincludes', 32 'gcc/ada', 33 'gcc/analyzer', 34 'gcc/brig', 35 'gcc/c', 36 'gcc/c-family', 37 'gcc', 38 'gcc/cp', 39 'gcc/d', 40 'gcc/fortran', 41 'gcc/go', 42 'gcc/jit', 43 'gcc/lto', 44 'gcc/objc', 45 'gcc/objcp', 46 'gcc/po', 47 'gcc/testsuite', 48 'gnattools', 49 'gotools', 50 'include', 51 'intl', 52 'libada', 53 'libatomic', 54 'libbacktrace', 55 'libcc1', 56 'libcody', 57 'libcpp', 58 'libcpp/po', 59 'libdecnumber', 60 'libffi', 61 'libgcc', 62 'libgcc/config/avr/libf7', 63 'libgcc/config/libbid', 64 'libgfortran', 65 'libgomp', 66 'libhsail-rt', 67 'libiberty', 68 'libitm', 69 'libobjc', 70 'liboffloadmic', 71 'libphobos', 72 'libquadmath', 73 'libsanitizer', 74 'libssp', 75 'libstdc++-v3', 76 'libvtv', 77 'lto-plugin', 78 'maintainer-scripts', 79 'zlib'} 80 81bug_components = { 82 'ada', 83 'analyzer', 84 'boehm-gc', 85 'bootstrap', 86 'c', 87 'c++', 88 'd', 89 'debug', 90 'demangler', 91 'driver', 92 'fastjar', 93 'fortran', 94 'gcov-profile', 95 'go', 96 'hsa', 97 'inline-asm', 98 'ipa', 99 'java', 100 'jit', 101 'libbacktrace', 102 'libf2c', 103 'libffi', 104 'libfortran', 105 'libgcc', 106 'libgcj', 107 'libgomp', 108 'libitm', 109 'libobjc', 110 'libquadmath', 111 'libstdc++', 112 'lto', 113 'middle-end', 114 'modula2', 115 'objc', 116 'objc++', 117 'other', 118 'pch', 119 'pending', 120 'plugins', 121 'preprocessor', 122 'regression', 123 'rtl-optimization', 124 'sanitizer', 125 'spam', 126 'target', 127 'testsuite', 128 'translation', 129 'tree-optimization', 130 'web'} 131 132ignored_prefixes = { 133 'gcc/d/dmd/', 134 'gcc/go/gofrontend/', 135 'gcc/testsuite/gdc.test/', 136 'gcc/testsuite/go.test/test/', 137 'libgo/', 138 'libphobos/libdruntime/', 139 'libphobos/src/', 140 'libsanitizer/', 141 } 142 143wildcard_prefixes = { 144 'gcc/testsuite/', 145 'libstdc++-v3/doc/html/', 146 'libstdc++-v3/testsuite/' 147 } 148 149misc_files = { 150 'gcc/DATESTAMP', 151 'gcc/BASE-VER', 152 'gcc/DEV-PHASE' 153 } 154 155author_line_regex = \ 156 re.compile(r'^(?P<datetime>\d{4}-\d{2}-\d{2})\ {2}(?P<name>.* <.*>)') 157additional_author_regex = re.compile(r'^\t(?P<spaces>\ *)?(?P<name>.* <.*>)') 158changelog_regex = re.compile(r'^(?:[fF]or +)?([a-z0-9+-/]*)ChangeLog:?') 159pr_regex = re.compile(r'\tPR (?P<component>[a-z+-]+\/)?([0-9]+)$') 160dr_regex = re.compile(r'\tDR ([0-9]+)$') 161star_prefix_regex = re.compile(r'\t\*(?P<spaces>\ *)(?P<content>.*)') 162end_of_location_regex = re.compile(r'[\[<(:]') 163item_empty_regex = re.compile(r'\t(\* \S+ )?\(\S+\):\s*$') 164item_parenthesis_regex = re.compile(r'\t(\*|\(\S+\):)') 165revert_regex = re.compile(r'This reverts commit (?P<hash>\w+).$') 166cherry_pick_regex = re.compile(r'cherry picked from commit (?P<hash>\w+)') 167 168LINE_LIMIT = 100 169TAB_WIDTH = 8 170CO_AUTHORED_BY_PREFIX = 'co-authored-by: ' 171 172REVIEW_PREFIXES = ('reviewed-by: ', 'reviewed-on: ', 'signed-off-by: ', 173 'acked-by: ', 'tested-by: ', 'reported-by: ', 174 'suggested-by: ') 175DATE_FORMAT = '%Y-%m-%d' 176 177 178def decode_path(path): 179 # When core.quotepath is true (default value), utf8 chars are encoded like: 180 # "b/ko\304\215ka.txt" 181 # 182 # The upstream bug is fixed: 183 # https://github.com/gitpython-developers/GitPython/issues/1099 184 # 185 # but we still need a workaround for older versions of the library. 186 # Please take a look at the explanation of the transformation: 187 # https://stackoverflow.com/questions/990169/how-do-convert-unicode-escape-sequences-to-unicode-characters-in-a-python-string 188 189 if path.startswith('"') and path.endswith('"'): 190 return (path.strip('"').encode('utf8').decode('unicode-escape') 191 .encode('latin-1').decode('utf8')) 192 else: 193 return path 194 195 196class Error: 197 def __init__(self, message, line=None): 198 self.message = message 199 self.line = line 200 201 def __repr__(self): 202 s = self.message 203 if self.line: 204 s += ': "%s"' % self.line 205 return s 206 207 208class ChangeLogEntry: 209 def __init__(self, folder, authors, prs): 210 self.folder = folder 211 # The 'list.copy()' function is not available before Python 3.3 212 self.author_lines = list(authors) 213 self.initial_prs = list(prs) 214 self.prs = list(prs) 215 self.lines = [] 216 self.files = [] 217 self.file_patterns = [] 218 self.opened_parentheses = 0 219 220 def parse_file_names(self): 221 # Whether the content currently processed is between a star prefix the 222 # end of the file list: a colon or an open paren. 223 in_location = False 224 225 for line in self.lines: 226 # If this line matches the star prefix, start the location 227 # processing on the information that follows the star. 228 # Note that we need to skip macro names that can be in form of: 229 # 230 # * config/i386/i386.md (*fix_trunc<mode>_i387_1, 231 # *add<mode>3_ne, *add<mode>3_eq_0, *add<mode>3_ne_0, 232 # *fist<mode>2_<rounding>_1, *<code><mode>3_1): 233 # 234 m = star_prefix_regex.match(line) 235 if m and len(m.group('spaces')) == 1: 236 in_location = True 237 line = m.group('content') 238 239 if in_location: 240 # Strip everything that is not a filename in "line": 241 # entities "(NAME)", cases "<PATTERN>", conditions 242 # "[COND]", entry text (the colon, if present, and 243 # anything that follows it). 244 m = end_of_location_regex.search(line) 245 if m: 246 line = line[:m.start()] 247 in_location = False 248 249 # At this point, all that's left is a list of filenames 250 # separated by commas and whitespaces. 251 for file in line.split(','): 252 file = file.strip() 253 if file: 254 if file.endswith('*'): 255 self.file_patterns.append(file[:-1]) 256 else: 257 self.files.append(file) 258 259 @property 260 def datetime(self): 261 for author in self.author_lines: 262 if author[1]: 263 return author[1] 264 return None 265 266 @property 267 def authors(self): 268 return [author_line[0] for author_line in self.author_lines] 269 270 @property 271 def is_empty(self): 272 return not self.lines and self.prs == self.initial_prs 273 274 def contains_author(self, author): 275 for author_lines in self.author_lines: 276 if author_lines[0] == author: 277 return True 278 return False 279 280 281class GitInfo: 282 def __init__(self, hexsha, date, author, lines, modified_files): 283 self.hexsha = hexsha 284 self.date = date 285 self.author = author 286 self.lines = lines 287 self.modified_files = modified_files 288 289 290class GitCommit: 291 def __init__(self, info, commit_to_info_hook=None, ref_name=None): 292 self.original_info = info 293 self.info = info 294 self.message = None 295 self.changes = None 296 self.changelog_entries = [] 297 self.errors = [] 298 self.top_level_authors = [] 299 self.co_authors = [] 300 self.top_level_prs = [] 301 self.cherry_pick_commit = None 302 self.revert_commit = None 303 self.commit_to_info_hook = commit_to_info_hook 304 self.init_changelog_locations(ref_name) 305 306 # Skip Update copyright years commits 307 if self.info.lines and self.info.lines[0] == 'Update copyright years.': 308 return 309 310 # Identify first if the commit is a Revert commit 311 for line in self.info.lines: 312 m = revert_regex.match(line) 313 if m: 314 self.revert_commit = m.group('hash') 315 break 316 if self.revert_commit: 317 self.info = self.commit_to_info_hook(self.revert_commit) 318 319 # Allow complete deletion of ChangeLog files in a commit 320 project_files = [f for f in self.info.modified_files 321 if (self.is_changelog_filename(f[0], allow_suffix=True) and f[1] != 'D') 322 or f[0] in misc_files] 323 ignored_files = [f for f in self.info.modified_files 324 if self.in_ignored_location(f[0])] 325 if len(project_files) == len(self.info.modified_files): 326 # All modified files are only MISC files 327 return 328 elif project_files: 329 self.errors.append(Error('ChangeLog, DATESTAMP, BASE-VER and ' 330 'DEV-PHASE updates should be done ' 331 'separately from normal commits')) 332 return 333 334 all_are_ignored = (len(project_files) + len(ignored_files) 335 == len(self.info.modified_files)) 336 self.parse_lines(all_are_ignored) 337 if self.changes: 338 self.parse_changelog() 339 self.parse_file_names() 340 self.check_for_empty_description() 341 self.check_for_broken_parentheses() 342 self.deduce_changelog_locations() 343 self.check_file_patterns() 344 if not self.errors: 345 self.check_mentioned_files() 346 self.check_for_correct_changelog() 347 348 @property 349 def success(self): 350 return not self.errors 351 352 @property 353 def new_files(self): 354 return [x[0] for x in self.info.modified_files if x[1] == 'A'] 355 356 @classmethod 357 def is_changelog_filename(cls, path, allow_suffix=False): 358 basename = os.path.basename(path) 359 if basename == 'ChangeLog': 360 return True 361 elif allow_suffix and basename.startswith('ChangeLog'): 362 return True 363 else: 364 return False 365 366 def find_changelog_location(self, name): 367 if name.startswith('\t'): 368 name = name[1:] 369 if name.endswith(':'): 370 name = name[:-1] 371 if name.endswith('/'): 372 name = name[:-1] 373 return name if name in self.changelog_locations else None 374 375 @classmethod 376 def format_git_author(cls, author): 377 assert '<' in author 378 return author.replace('<', ' <') 379 380 @classmethod 381 def parse_git_name_status(cls, string): 382 modified_files = [] 383 for entry in string.split('\n'): 384 parts = entry.split('\t') 385 t = parts[0] 386 if t == 'A' or t == 'D' or t == 'M': 387 modified_files.append((parts[1], t)) 388 elif t.startswith('R'): 389 modified_files.append((parts[1], 'D')) 390 modified_files.append((parts[2], 'A')) 391 return modified_files 392 393 def init_changelog_locations(self, ref_name): 394 self.changelog_locations = list(default_changelog_locations) 395 if ref_name: 396 version = sys.maxsize 397 if 'releases/gcc-' in ref_name: 398 version = int(ref_name.split('-')[-1]) 399 if version >= 12: 400 # HSA and BRIG were removed in GCC 12 401 self.changelog_locations.remove('gcc/brig') 402 self.changelog_locations.remove('libhsail-rt') 403 404 def parse_lines(self, all_are_ignored): 405 body = self.info.lines 406 407 for i, b in enumerate(body): 408 if not b: 409 continue 410 if (changelog_regex.match(b) or self.find_changelog_location(b) 411 or star_prefix_regex.match(b) or pr_regex.match(b) 412 or dr_regex.match(b) or author_line_regex.match(b) 413 or b.lower().startswith(CO_AUTHORED_BY_PREFIX)): 414 self.changes = body[i:] 415 return 416 if not all_are_ignored: 417 self.errors.append(Error('cannot find a ChangeLog location in ' 418 'message')) 419 420 def parse_changelog(self): 421 last_entry = None 422 will_deduce = False 423 for line in self.changes: 424 if not line: 425 if last_entry and will_deduce: 426 last_entry = None 427 continue 428 if line != line.rstrip(): 429 self.errors.append(Error('trailing whitespace', line)) 430 if len(line.replace('\t', ' ' * TAB_WIDTH)) > LINE_LIMIT: 431 # support long filenames 432 if not line.startswith('\t* ') or not line.endswith(':') or ' ' in line[3:-1]: 433 self.errors.append(Error('line exceeds %d character limit' 434 % LINE_LIMIT, line)) 435 m = changelog_regex.match(line) 436 if m: 437 last_entry = ChangeLogEntry(m.group(1).rstrip('/'), 438 self.top_level_authors, 439 self.top_level_prs) 440 self.changelog_entries.append(last_entry) 441 elif self.find_changelog_location(line): 442 last_entry = ChangeLogEntry(self.find_changelog_location(line), 443 self.top_level_authors, 444 self.top_level_prs) 445 self.changelog_entries.append(last_entry) 446 else: 447 author_tuple = None 448 pr_line = None 449 if author_line_regex.match(line): 450 m = author_line_regex.match(line) 451 author_tuple = (m.group('name'), m.group('datetime')) 452 elif additional_author_regex.match(line): 453 m = additional_author_regex.match(line) 454 if len(m.group('spaces')) != 4: 455 msg = 'additional author must be indented with '\ 456 'one tab and four spaces' 457 self.errors.append(Error(msg, line)) 458 else: 459 author_tuple = (m.group('name'), None) 460 elif pr_regex.match(line): 461 component = pr_regex.match(line).group('component') 462 if not component: 463 self.errors.append(Error('missing PR component', line)) 464 continue 465 elif not component[:-1] in bug_components: 466 self.errors.append(Error('invalid PR component', line)) 467 continue 468 else: 469 pr_line = line.lstrip() 470 elif dr_regex.match(line): 471 pr_line = line.lstrip() 472 473 lowered_line = line.lower() 474 if lowered_line.startswith(CO_AUTHORED_BY_PREFIX): 475 name = line[len(CO_AUTHORED_BY_PREFIX):] 476 author = self.format_git_author(name) 477 self.co_authors.append(author) 478 continue 479 elif lowered_line.startswith(REVIEW_PREFIXES): 480 continue 481 else: 482 m = cherry_pick_regex.search(line) 483 if m: 484 commit = m.group('hash') 485 if self.cherry_pick_commit: 486 msg = 'multiple cherry pick lines' 487 self.errors.append(Error(msg, line)) 488 else: 489 self.cherry_pick_commit = commit 490 continue 491 492 # ChangeLog name will be deduced later 493 if not last_entry: 494 if author_tuple: 495 self.top_level_authors.append(author_tuple) 496 continue 497 elif pr_line: 498 # append to top_level_prs only when we haven't met 499 # a ChangeLog entry 500 if (pr_line not in self.top_level_prs 501 and not self.changelog_entries): 502 self.top_level_prs.append(pr_line) 503 continue 504 else: 505 last_entry = ChangeLogEntry(None, 506 self.top_level_authors, 507 self.top_level_prs) 508 self.changelog_entries.append(last_entry) 509 will_deduce = True 510 elif author_tuple: 511 if not last_entry.contains_author(author_tuple[0]): 512 last_entry.author_lines.append(author_tuple) 513 continue 514 515 if not line.startswith('\t'): 516 err = Error('line should start with a tab', line) 517 self.errors.append(err) 518 elif pr_line: 519 last_entry.prs.append(pr_line) 520 else: 521 m = star_prefix_regex.match(line) 522 if m: 523 if (len(m.group('spaces')) != 1 and 524 last_entry.opened_parentheses == 0): 525 msg = 'one space should follow asterisk' 526 self.errors.append(Error(msg, line)) 527 else: 528 content = m.group('content') 529 parts = content.split(':') 530 if len(parts) > 1: 531 for needle in ('()', '[]', '<>'): 532 if ' ' + needle in parts[0]: 533 msg = f'empty group "{needle}" found' 534 self.errors.append(Error(msg, line)) 535 last_entry.lines.append(line) 536 self.process_parentheses(last_entry, line) 537 else: 538 if last_entry.is_empty: 539 msg = 'first line should start with a tab, ' \ 540 'an asterisk and a space' 541 self.errors.append(Error(msg, line)) 542 else: 543 last_entry.lines.append(line) 544 self.process_parentheses(last_entry, line) 545 546 def process_parentheses(self, last_entry, line): 547 for c in line: 548 if c == '(': 549 last_entry.opened_parentheses += 1 550 elif c == ')': 551 if last_entry.opened_parentheses == 0: 552 msg = 'bad wrapping of parenthesis' 553 self.errors.append(Error(msg, line)) 554 else: 555 last_entry.opened_parentheses -= 1 556 557 def parse_file_names(self): 558 for entry in self.changelog_entries: 559 entry.parse_file_names() 560 561 def check_file_patterns(self): 562 for entry in self.changelog_entries: 563 for pattern in entry.file_patterns: 564 name = os.path.join(entry.folder, pattern) 565 if not [name.startswith(pr) for pr in wildcard_prefixes]: 566 msg = 'unsupported wildcard prefix' 567 self.errors.append(Error(msg, name)) 568 569 def check_for_empty_description(self): 570 for entry in self.changelog_entries: 571 for i, line in enumerate(entry.lines): 572 if (item_empty_regex.match(line) and 573 (i == len(entry.lines) - 1 574 or not entry.lines[i+1].strip() 575 or item_parenthesis_regex.match(entry.lines[i+1]))): 576 msg = 'missing description of a change' 577 self.errors.append(Error(msg, line)) 578 579 def check_for_broken_parentheses(self): 580 for entry in self.changelog_entries: 581 if entry.opened_parentheses != 0: 582 msg = 'bad parentheses wrapping' 583 self.errors.append(Error(msg, entry.lines[0])) 584 585 def get_file_changelog_location(self, changelog_file): 586 for file in self.info.modified_files: 587 if file[0] == changelog_file: 588 # root ChangeLog file 589 return '' 590 index = file[0].find('/' + changelog_file) 591 if index != -1: 592 return file[0][:index] 593 return None 594 595 def deduce_changelog_locations(self): 596 for entry in self.changelog_entries: 597 if not entry.folder: 598 changelog = None 599 for file in entry.files: 600 location = self.get_file_changelog_location(file) 601 if (location == '' 602 or (location and location in self.changelog_locations)): 603 if changelog and changelog != location: 604 msg = 'could not deduce ChangeLog file, ' \ 605 'not unique location' 606 self.errors.append(Error(msg)) 607 return 608 changelog = location 609 if changelog is not None: 610 entry.folder = changelog 611 else: 612 msg = 'could not deduce ChangeLog file' 613 self.errors.append(Error(msg)) 614 615 @classmethod 616 def in_ignored_location(cls, path): 617 for ignored in ignored_prefixes: 618 if path.startswith(ignored): 619 return True 620 return False 621 622 def get_changelog_by_path(self, path): 623 components = path.split('/') 624 while components: 625 if '/'.join(components) in self.changelog_locations: 626 break 627 components = components[:-1] 628 return '/'.join(components) 629 630 def check_mentioned_files(self): 631 folder_count = len([x.folder for x in self.changelog_entries]) 632 assert folder_count == len(self.changelog_entries) 633 634 mentioned_files = set() 635 mentioned_patterns = [] 636 used_patterns = set() 637 for entry in self.changelog_entries: 638 if not entry.files and not entry.file_patterns: 639 msg = 'no files mentioned for ChangeLog in directory' 640 self.errors.append(Error(msg, entry.folder)) 641 assert not entry.folder.endswith('/') 642 for file in entry.files: 643 if not self.is_changelog_filename(file): 644 item = os.path.join(entry.folder, file) 645 if item in mentioned_files: 646 msg = 'same file specified multiple times' 647 self.errors.append(Error(msg, file)) 648 else: 649 mentioned_files.add(item) 650 for pattern in entry.file_patterns: 651 mentioned_patterns.append(os.path.join(entry.folder, pattern)) 652 653 cand = [x[0] for x in self.info.modified_files 654 if not self.is_changelog_filename(x[0])] 655 changed_files = set(cand) 656 for file in sorted(mentioned_files - changed_files): 657 msg = 'unchanged file mentioned in a ChangeLog' 658 candidates = difflib.get_close_matches(file, changed_files, 1) 659 if candidates: 660 msg += f' (did you mean "{candidates[0]}"?)' 661 self.errors.append(Error(msg, file)) 662 for file in sorted(changed_files - mentioned_files): 663 if not self.in_ignored_location(file): 664 if file in self.new_files: 665 changelog_location = self.get_changelog_by_path(file) 666 # Python2: we cannot use next(filter(...)) 667 entries = filter(lambda x: x.folder == changelog_location, 668 self.changelog_entries) 669 entries = list(entries) 670 entry = entries[0] if entries else None 671 if not entry: 672 prs = self.top_level_prs 673 if not prs: 674 # if all ChangeLog entries have identical PRs 675 # then use them 676 prs = self.changelog_entries[0].prs 677 for entry in self.changelog_entries: 678 if entry.prs != prs: 679 prs = [] 680 break 681 entry = ChangeLogEntry(changelog_location, 682 self.top_level_authors, 683 prs) 684 self.changelog_entries.append(entry) 685 # strip prefix of the file 686 assert file.startswith(entry.folder) 687 file = file[len(entry.folder):].lstrip('/') 688 entry.lines.append('\t* %s: New file.' % file) 689 entry.files.append(file) 690 else: 691 used_pattern = [p for p in mentioned_patterns 692 if file.startswith(p)] 693 used_pattern = used_pattern[0] if used_pattern else None 694 if used_pattern: 695 used_patterns.add(used_pattern) 696 else: 697 msg = 'changed file not mentioned in a ChangeLog' 698 self.errors.append(Error(msg, file)) 699 700 for pattern in mentioned_patterns: 701 if pattern not in used_patterns: 702 error = "pattern doesn't match any changed files" 703 self.errors.append(Error(error, pattern)) 704 705 def check_for_correct_changelog(self): 706 for entry in self.changelog_entries: 707 for file in entry.files: 708 full_path = os.path.join(entry.folder, file) 709 changelog_location = self.get_changelog_by_path(full_path) 710 if changelog_location != entry.folder: 711 msg = 'wrong ChangeLog location "%s", should be "%s"' 712 err = Error(msg % (entry.folder, changelog_location), file) 713 self.errors.append(err) 714 715 @classmethod 716 def format_authors_in_changelog(cls, authors, timestamp, prefix=''): 717 output = '' 718 for i, author in enumerate(authors): 719 if i == 0: 720 output += '%s%s %s\n' % (prefix, timestamp, author) 721 else: 722 output += '%s\t %s\n' % (prefix, author) 723 output += '\n' 724 return output 725 726 def to_changelog_entries(self, use_commit_ts=False): 727 current_timestamp = self.info.date.strftime(DATE_FORMAT) 728 for entry in self.changelog_entries: 729 output = '' 730 timestamp = entry.datetime 731 if self.revert_commit: 732 timestamp = current_timestamp 733 orig_date = self.original_info.date 734 current_timestamp = orig_date.strftime(DATE_FORMAT) 735 elif self.cherry_pick_commit: 736 info = self.commit_to_info_hook(self.cherry_pick_commit) 737 # it can happen that it is a cherry-pick for a different 738 # repository 739 if info: 740 timestamp = info.date.strftime(DATE_FORMAT) 741 else: 742 timestamp = current_timestamp 743 elif not timestamp or use_commit_ts: 744 timestamp = current_timestamp 745 authors = entry.authors if entry.authors else [self.info.author] 746 # add Co-Authored-By authors to all ChangeLog entries 747 for author in self.co_authors: 748 if author not in authors: 749 authors.append(author) 750 751 if self.cherry_pick_commit or self.revert_commit: 752 original_author = self.original_info.author 753 output += self.format_authors_in_changelog([original_author], 754 current_timestamp) 755 if self.revert_commit: 756 output += '\tRevert:\n' 757 else: 758 output += '\tBackported from master:\n' 759 output += self.format_authors_in_changelog(authors, 760 timestamp, '\t') 761 else: 762 output += self.format_authors_in_changelog(authors, timestamp) 763 for pr in entry.prs: 764 output += '\t%s\n' % pr 765 for line in entry.lines: 766 output += line + '\n' 767 yield (entry.folder, output.rstrip()) 768 769 def print_output(self): 770 for entry, output in self.to_changelog_entries(): 771 print('------ %s/ChangeLog ------ ' % entry) 772 print(output) 773 774 def print_errors(self): 775 print('Errors:') 776 for error in self.errors: 777 print(error) 778