1# Copyright 2020 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""Methods related to outputting script results in a human-readable format. 5 6Also probably a good example of how to *not* write HTML. 7""" 8 9from __future__ import print_function 10 11import collections 12import logging 13import sys 14import tempfile 15from typing import Any, Dict, IO, List, Optional, OrderedDict, Set, Tuple, Union 16 17import six 18 19from unexpected_passes_common import data_types 20 21# Used for posting Buganizer comments. 22from blinkpy.w3c import buganizer 23 24FULL_PASS = 'Fully passed in the following' 25PARTIAL_PASS = 'Partially passed in the following' 26NEVER_PASS = 'Never passed in the following' 27 28HTML_HEADER = """\ 29<!DOCTYPE html> 30<html> 31<head> 32<meta content="width=device-width"> 33<style> 34.collapsible_group { 35 background-color: #757575; 36 border: none; 37 color: white; 38 font-size:20px; 39 outline: none; 40 text-align: left; 41 width: 100%; 42} 43.active_collapsible_group, .collapsible_group:hover { 44 background-color: #474747; 45} 46.highlighted_collapsible_group { 47 background-color: #008000; 48 border: none; 49 color: white; 50 font-size:20px; 51 outline: none; 52 text-align: left; 53 width: 100%; 54} 55.active_highlighted_collapsible_group, .highlighted_collapsible_group:hover { 56 background-color: #004d00; 57} 58.content { 59 background-color: #e1e4e8; 60 display: none; 61 padding: 0 25px; 62} 63button { 64 user-select: text; 65} 66h1 { 67 background-color: black; 68 color: white; 69} 70</style> 71</head> 72<body> 73""" 74 75HTML_FOOTER = """\ 76<script> 77function OnClickImpl(element) { 78 let sibling = element.nextElementSibling; 79 if (sibling.style.display === "block") { 80 sibling.style.display = "none"; 81 } else { 82 sibling.style.display = "block"; 83 } 84} 85 86function OnClick() { 87 this.classList.toggle("active_collapsible_group"); 88 OnClickImpl(this); 89} 90 91function OnClickHighlighted() { 92 this.classList.toggle("active_highlighted_collapsible_group"); 93 OnClickImpl(this); 94} 95 96// Repeatedly bubble up the highlighted_collapsible_group class as long as all 97// siblings are highlighted. 98let found_element_to_convert = false; 99do { 100 found_element_to_convert = false; 101 // Get an initial list of all highlighted_collapsible_groups. 102 let highlighted_collapsible_groups = document.getElementsByClassName( 103 "highlighted_collapsible_group"); 104 let highlighted_list = []; 105 for (elem of highlighted_collapsible_groups) { 106 highlighted_list.push(elem); 107 } 108 109 // Bubble up the highlighted_collapsible_group class. 110 while (highlighted_list.length) { 111 elem = highlighted_list.shift(); 112 if (elem.tagName == 'BODY') { 113 continue; 114 } 115 if (elem.classList.contains("content")) { 116 highlighted_list.push(elem.previousElementSibling); 117 continue; 118 } 119 if (elem.classList.contains("collapsible_group")) { 120 found_element_to_convert = true; 121 elem.classList.add("highlighted_collapsible_group"); 122 elem.classList.remove("collapsible_group"); 123 } 124 125 sibling_elements = elem.parentElement.children; 126 let found_non_highlighted_group = false; 127 for (e of sibling_elements) { 128 if (e.classList.contains("collapsible_group")) { 129 found_non_highlighted_group = true; 130 break 131 } 132 } 133 if (!found_non_highlighted_group) { 134 highlighted_list.push(elem.parentElement); 135 } 136 } 137} while (found_element_to_convert); 138 139// Apply OnClick listeners so [highlighted_]collapsible_groups properly 140// shrink/expand. 141let collapsible_groups = document.getElementsByClassName("collapsible_group"); 142for (element of collapsible_groups) { 143 element.addEventListener("click", OnClick); 144} 145 146highlighted_collapsible_groups = document.getElementsByClassName( 147 "highlighted_collapsible_group"); 148for (element of highlighted_collapsible_groups) { 149 element.addEventListener("click", OnClickHighlighted); 150} 151</script> 152</body> 153</html> 154""" 155 156SECTION_STALE = 'Stale Expectations (Passed 100% Everywhere, Can Remove)' 157SECTION_SEMI_STALE = ('Semi Stale Expectations (Passed 100% In Some Places, ' 158 'But Not Everywhere - Can Likely Be Modified But Not ' 159 'Necessarily Removed)') 160SECTION_ACTIVE = ('Active Expectations (Failed At Least Once Everywhere, ' 161 'Likely Should Be Left Alone)') 162SECTION_UNMATCHED = ('Unmatched Results (An Expectation Existed When The Test ' 163 'Ran, But No Matching One Currently Exists OR The ' 164 'Expectation Is Too New)') 165SECTION_UNUSED = ('Unused Expectations (Indicative Of The Configuration No ' 166 'Longer Being Tested Or Tags Changing)') 167 168MAX_BUGS_PER_LINE = 5 169MAX_CHARACTERS_PER_CL_LINE = 72 170 171BUGANIZER_COMMENT = ('The unexpected pass finder removed the last expectation ' 172 'associated with this bug. An associated CL should be ' 173 'landing shortly, after which this bug can be closed once ' 174 'a human confirms there is no more work to be done.') 175 176ElementType = Union[Dict[str, Any], List[str], str] 177# Sample: 178# { 179# expectation_file: { 180# test_name: { 181# expectation_summary: { 182# builder_name: { 183# 'Fully passed in the following': [ 184# step1, 185# ], 186# 'Partially passed in the following': { 187# step2: [ 188# failure_link, 189# ], 190# }, 191# 'Never passed in the following': [ 192# step3, 193# ], 194# } 195# } 196# } 197# } 198# } 199FullOrNeverPassValue = List[str] 200PartialPassValue = Dict[str, List[str]] 201PassValue = Union[FullOrNeverPassValue, PartialPassValue] 202BuilderToPassMap = Dict[str, Dict[str, PassValue]] 203ExpectationToBuilderMap = Dict[str, BuilderToPassMap] 204TestToExpectationMap = Dict[str, ExpectationToBuilderMap] 205ExpectationFileStringDict = Dict[str, TestToExpectationMap] 206# Sample: 207# { 208# test_name: { 209# builder_name: { 210# step_name: [ 211# individual_result_string_1, 212# individual_result_string_2, 213# ... 214# ], 215# ... 216# }, 217# ... 218# }, 219# ... 220# } 221StepToResultsMap = Dict[str, List[str]] 222BuilderToStepMap = Dict[str, StepToResultsMap] 223TestToBuilderStringDict = Dict[str, BuilderToStepMap] 224# Sample: 225# { 226# result_output.FULL_PASS: { 227# builder_name: [ 228# step_name (total passes / total builds) 229# ], 230# }, 231# result_output.NEVER_PASS: { 232# builder_name: [ 233# step_name (total passes / total builds) 234# ], 235# }, 236# result_output.PARTIAL_PASS: { 237# builder_name: { 238# step_name (total passes / total builds): [ 239# failure links, 240# ], 241# }, 242# }, 243# } 244FullOrNeverPassStepValue = List[str] 245PartialPassStepValue = Dict[str, List[str]] 246PassStepValue = Union[FullOrNeverPassStepValue, PartialPassStepValue] 247 248UnmatchedResultsType = Dict[str, data_types.ResultListType] 249UnusedExpectation = Dict[str, List[data_types.Expectation]] 250 251RemovedUrlsType = Union[List[str], Set[str]] 252 253 254def OutputResults(stale_dict: data_types.TestExpectationMap, 255 semi_stale_dict: data_types.TestExpectationMap, 256 active_dict: data_types.TestExpectationMap, 257 unmatched_results: UnmatchedResultsType, 258 unused_expectations: UnusedExpectation, 259 output_format: str, 260 file_handle: Optional[IO] = None) -> None: 261 """Outputs script results to |file_handle|. 262 263 Args: 264 stale_dict: A data_types.TestExpectationMap containing all the stale 265 expectations. 266 semi_stale_dict: A data_types.TestExpectationMap containing all the 267 semi-stale expectations. 268 active_dict: A data_types.TestExpectationmap containing all the active 269 expectations. 270 ummatched_results: Any unmatched results found while filling 271 |test_expectation_map|, as returned by 272 queries.FillExpectationMapFor[Ci|Try]Builders(). 273 unused_expectations: A dict from expectation file (str) to list of 274 unmatched Expectations that were pulled out of |test_expectation_map| 275 output_format: A string denoting the format to output to. Valid values are 276 "print" and "html". 277 file_handle: An optional open file-like object to output to. If not 278 specified, a suitable default will be used. 279 """ 280 assert isinstance(stale_dict, data_types.TestExpectationMap) 281 assert isinstance(semi_stale_dict, data_types.TestExpectationMap) 282 assert isinstance(active_dict, data_types.TestExpectationMap) 283 logging.info('Outputting results in format %s', output_format) 284 stale_str_dict = _ConvertTestExpectationMapToStringDict(stale_dict) 285 semi_stale_str_dict = _ConvertTestExpectationMapToStringDict(semi_stale_dict) 286 active_str_dict = _ConvertTestExpectationMapToStringDict(active_dict) 287 unmatched_results_str_dict = _ConvertUnmatchedResultsToStringDict( 288 unmatched_results) 289 unused_expectations_str_list = _ConvertUnusedExpectationsToStringDict( 290 unused_expectations) 291 292 if output_format == 'print': 293 file_handle = file_handle or sys.stdout 294 if stale_dict: 295 file_handle.write(SECTION_STALE + '\n') 296 RecursivePrintToFile(stale_str_dict, 0, file_handle) 297 if semi_stale_dict: 298 file_handle.write(SECTION_SEMI_STALE + '\n') 299 RecursivePrintToFile(semi_stale_str_dict, 0, file_handle) 300 if active_dict: 301 file_handle.write(SECTION_ACTIVE + '\n') 302 RecursivePrintToFile(active_str_dict, 0, file_handle) 303 304 if unused_expectations_str_list: 305 file_handle.write('\n' + SECTION_UNUSED + '\n') 306 RecursivePrintToFile(unused_expectations_str_list, 0, file_handle) 307 if unmatched_results_str_dict: 308 file_handle.write('\n' + SECTION_UNMATCHED + '\n') 309 RecursivePrintToFile(unmatched_results_str_dict, 0, file_handle) 310 311 elif output_format == 'html': 312 should_close_file = False 313 if not file_handle: 314 should_close_file = True 315 file_handle = tempfile.NamedTemporaryFile(delete=False, 316 suffix='.html', 317 mode='w') 318 319 file_handle.write(HTML_HEADER) 320 if stale_dict: 321 file_handle.write('<h1>' + SECTION_STALE + '</h1>\n') 322 _RecursiveHtmlToFile(stale_str_dict, file_handle) 323 if semi_stale_dict: 324 file_handle.write('<h1>' + SECTION_SEMI_STALE + '</h1>\n') 325 _RecursiveHtmlToFile(semi_stale_str_dict, file_handle) 326 if active_dict: 327 file_handle.write('<h1>' + SECTION_ACTIVE + '</h1>\n') 328 _RecursiveHtmlToFile(active_str_dict, file_handle) 329 330 if unused_expectations_str_list: 331 file_handle.write('\n<h1>' + SECTION_UNUSED + "</h1>\n") 332 _RecursiveHtmlToFile(unused_expectations_str_list, file_handle) 333 if unmatched_results_str_dict: 334 file_handle.write('\n<h1>' + SECTION_UNMATCHED + '</h1>\n') 335 _RecursiveHtmlToFile(unmatched_results_str_dict, file_handle) 336 337 file_handle.write(HTML_FOOTER) 338 if should_close_file: 339 file_handle.close() 340 print('Results available at file://%s' % file_handle.name) 341 else: 342 raise RuntimeError('Unsupported output format %s' % output_format) 343 344 345def RecursivePrintToFile(element: ElementType, depth: int, 346 file_handle: IO) -> None: 347 """Recursively prints |element| as text to |file_handle|. 348 349 Args: 350 element: A dict, list, or str/unicode to output. 351 depth: The current depth of the recursion as an int. 352 file_handle: An open file-like object to output to. 353 """ 354 if element is None: 355 element = str(element) 356 if isinstance(element, six.string_types): 357 file_handle.write((' ' * depth) + element + '\n') 358 elif isinstance(element, dict): 359 for k, v in element.items(): 360 RecursivePrintToFile(k, depth, file_handle) 361 RecursivePrintToFile(v, depth + 1, file_handle) 362 elif isinstance(element, list): 363 for i in element: 364 RecursivePrintToFile(i, depth, file_handle) 365 else: 366 raise RuntimeError('Given unhandled type %s' % type(element)) 367 368 369def _RecursiveHtmlToFile(element: ElementType, file_handle: IO) -> None: 370 """Recursively outputs |element| as HTMl to |file_handle|. 371 372 Iterables will be output as a collapsible section containing any of the 373 iterable's contents. 374 375 Any link-like text will be turned into anchor tags. 376 377 Args: 378 element: A dict, list, or str/unicode to output. 379 file_handle: An open file-like object to output to. 380 """ 381 if isinstance(element, six.string_types): 382 file_handle.write('<p>%s</p>\n' % _LinkifyString(element)) 383 elif isinstance(element, dict): 384 for k, v in element.items(): 385 html_class = 'collapsible_group' 386 # This allows us to later (in JavaScript) recursively highlight sections 387 # that are likely of interest to the user, i.e. whose expectations can be 388 # modified. 389 if k and FULL_PASS in k: 390 html_class = 'highlighted_collapsible_group' 391 file_handle.write('<button type="button" class="%s">%s</button>\n' % 392 (html_class, k)) 393 file_handle.write('<div class="content">\n') 394 _RecursiveHtmlToFile(v, file_handle) 395 file_handle.write('</div>\n') 396 elif isinstance(element, list): 397 for i in element: 398 _RecursiveHtmlToFile(i, file_handle) 399 else: 400 raise RuntimeError('Given unhandled type %s' % type(element)) 401 402 403def _LinkifyString(s: str) -> str: 404 """Turns instances of links into anchor tags. 405 406 Args: 407 s: The string to linkify. 408 409 Returns: 410 A copy of |s| with instances of links turned into anchor tags pointing to 411 the link. 412 """ 413 for component in s.split(): 414 if component.startswith('http'): 415 component = component.strip(',.!') 416 s = s.replace(component, '<a href="%s">%s</a>' % (component, component)) 417 return s 418 419 420def _ConvertTestExpectationMapToStringDict( 421 test_expectation_map: data_types.TestExpectationMap 422) -> ExpectationFileStringDict: 423 """Converts |test_expectation_map| to a dict of strings for reporting. 424 425 Args: 426 test_expectation_map: A data_types.TestExpectationMap. 427 428 Returns: 429 A string dictionary representation of |test_expectation_map| in the 430 following format: 431 { 432 expectation_file: { 433 test_name: { 434 expectation_summary: { 435 builder_name: { 436 'Fully passed in the following': [ 437 step1, 438 ], 439 'Partially passed in the following': { 440 step2: [ 441 failure_link, 442 ], 443 }, 444 'Never passed in the following': [ 445 step3, 446 ], 447 } 448 } 449 } 450 } 451 } 452 """ 453 assert isinstance(test_expectation_map, data_types.TestExpectationMap) 454 output_dict = {} 455 # This initially looks like a good target for using 456 # data_types.TestExpectationMap's iterators since there are many nested loops. 457 # However, we need to reset state in different loops, and the alternative of 458 # keeping all the state outside the loop and resetting under certain 459 # conditions ends up being less readable than just using nested loops. 460 for expectation_file, expectation_map in test_expectation_map.items(): 461 output_dict[expectation_file] = {} 462 463 for expectation, builder_map in expectation_map.items(): 464 test_name = expectation.test 465 expectation_str = _FormatExpectation(expectation) 466 output_dict[expectation_file].setdefault(test_name, {}) 467 output_dict[expectation_file][test_name][expectation_str] = {} 468 469 for builder_name, step_map in builder_map.items(): 470 output_dict[expectation_file][test_name][expectation_str][ 471 builder_name] = {} 472 fully_passed = [] 473 partially_passed = {} 474 never_passed = [] 475 476 for step_name, stats in step_map.items(): 477 if stats.NeverNeededExpectation(expectation): 478 fully_passed.append(AddStatsToStr(step_name, stats)) 479 elif stats.AlwaysNeededExpectation(expectation): 480 never_passed.append(AddStatsToStr(step_name, stats)) 481 else: 482 assert step_name not in partially_passed 483 partially_passed[step_name] = stats 484 485 output_builder_map = output_dict[expectation_file][test_name][ 486 expectation_str][builder_name] 487 if fully_passed: 488 output_builder_map[FULL_PASS] = fully_passed 489 if partially_passed: 490 output_builder_map[PARTIAL_PASS] = {} 491 for step_name, stats in partially_passed.items(): 492 s = AddStatsToStr(step_name, stats) 493 output_builder_map[PARTIAL_PASS][s] = list(stats.failure_links) 494 if never_passed: 495 output_builder_map[NEVER_PASS] = never_passed 496 return output_dict 497 498 499def _ConvertUnmatchedResultsToStringDict(unmatched_results: UnmatchedResultsType 500 ) -> TestToBuilderStringDict: 501 """Converts |unmatched_results| to a dict of strings for reporting. 502 503 Args: 504 unmatched_results: A dict mapping builder names (string) to lists of 505 data_types.Result who did not have a matching expectation. 506 507 Returns: 508 A string dictionary representation of |unmatched_results| in the following 509 format: 510 { 511 test_name: { 512 builder_name: { 513 step_name: [ 514 individual_result_string_1, 515 individual_result_string_2, 516 ... 517 ], 518 ... 519 }, 520 ... 521 }, 522 ... 523 } 524 """ 525 output_dict = {} 526 for builder, results in unmatched_results.items(): 527 for r in results: 528 builder_map = output_dict.setdefault(r.test, {}) 529 step_map = builder_map.setdefault(builder, {}) 530 result_str = 'Got "%s" on %s with tags [%s]' % ( 531 r.actual_result, data_types.BuildLinkFromBuildId( 532 r.build_id), ' '.join(r.tags)) 533 step_map.setdefault(r.step, []).append(result_str) 534 return output_dict 535 536 537def _ConvertUnusedExpectationsToStringDict( 538 unused_expectations: UnusedExpectation) -> Dict[str, List[str]]: 539 """Converts |unused_expectations| to a dict of strings for reporting. 540 541 Args: 542 unused_expectations: A dict mapping expectation file (str) to lists of 543 data_types.Expectation who did not have any matching results. 544 545 Returns: 546 A string dictionary representation of |unused_expectations| in the following 547 format: 548 { 549 expectation_file: [ 550 expectation1, 551 expectation2, 552 ], 553 } 554 The expectations are in a format similar to what would be present as a line 555 in an expectation file. 556 """ 557 output_dict = {} 558 for expectation_file, expectations in unused_expectations.items(): 559 expectation_str_list = [] 560 for e in expectations: 561 expectation_str_list.append(e.AsExpectationFileString()) 562 output_dict[expectation_file] = expectation_str_list 563 return output_dict 564 565 566def _FormatExpectation(expectation: data_types.Expectation) -> str: 567 return '"%s" expectation on "%s"' % (' '.join( 568 expectation.expected_results), ' '.join(expectation.tags)) 569 570 571def AddStatsToStr(s: str, stats: data_types.BuildStats) -> str: 572 return '%s %s' % (s, stats.GetStatsAsString()) 573 574 575def OutputAffectedUrls(removed_urls: RemovedUrlsType, 576 orphaned_urls: Optional[RemovedUrlsType] = None, 577 bug_file_handle: Optional[IO] = None, 578 auto_close_bugs: bool = True) -> None: 579 """Outputs URLs of affected expectations for easier consumption by the user. 580 581 Outputs the following: 582 583 1. A string suitable for passing to Chrome via the command line to 584 open all bugs in the browser. 585 2. A string suitable for copying into the CL description to associate the CL 586 with all the affected bugs. 587 3. A string containing any bugs that should be closable since there are no 588 longer any associated expectations. 589 590 Args: 591 removed_urls: A set or list of strings containing bug URLs. 592 orphaned_urls: A subset of |removed_urls| whose bugs no longer have any 593 corresponding expectations. 594 bug_file_handle: An optional open file-like object to write CL description 595 bug information to. If not specified, will print to the terminal. 596 auto_close_bugs: A boolean specifying whether bugs in |orphaned_urls| should 597 be auto-closed on CL submission or not. If not closed, a comment will 598 be posted instead. 599 """ 600 removed_urls = list(removed_urls) 601 removed_urls.sort() 602 orphaned_urls = orphaned_urls or [] 603 orphaned_urls = list(orphaned_urls) 604 orphaned_urls.sort() 605 _OutputAffectedUrls(removed_urls, orphaned_urls) 606 _OutputUrlsForClDescription(removed_urls, 607 orphaned_urls, 608 file_handle=bug_file_handle, 609 auto_close_bugs=auto_close_bugs) 610 611 612def _OutputAffectedUrls(affected_urls: List[str], 613 orphaned_urls: List[str], 614 file_handle: Optional[IO] = None) -> None: 615 """Outputs |urls| for opening in a browser as affected bugs. 616 617 Args: 618 affected_urls: A list of strings containing URLs to output. 619 orphaned_urls: A list of strings containing URLs to output as closable. 620 file_handle: A file handle to write the string to. Defaults to stdout. 621 """ 622 _OutputUrlsForCommandLine(affected_urls, "Affected bugs", file_handle) 623 if orphaned_urls: 624 _OutputUrlsForCommandLine(orphaned_urls, "Closable bugs", file_handle) 625 626 627def _OutputUrlsForCommandLine(urls: List[str], 628 description: str, 629 file_handle: Optional[IO] = None) -> None: 630 """Outputs |urls| for opening in a browser. 631 632 The output string is meant to be passed to a browser via the command line in 633 order to open all URLs in that browser, e.g. 634 635 `google-chrome https://crbug.com/1234 https://crbug.com/2345` 636 637 Args: 638 urls: A list of strings containing URLs to output. 639 description: A description of the URLs to be output. 640 file_handle: A file handle to write the string to. Defaults to stdout. 641 """ 642 file_handle = file_handle or sys.stdout 643 644 def _StartsWithHttp(url: str) -> bool: 645 return url.startswith('https://') or url.startswith('http://') 646 647 urls = [u if _StartsWithHttp(u) else 'https://%s' % u for u in urls] 648 file_handle.write('%s: %s\n' % (description, ' '.join(urls))) 649 650 651def _OutputUrlsForClDescription(affected_urls: List[str], 652 orphaned_urls: List[str], 653 file_handle: Optional[IO] = None, 654 auto_close_bugs: bool = True) -> None: 655 """Outputs |urls| for use in a CL description. 656 657 Output adheres to the line length recommendation and max number of bugs per 658 line supported in Gerrit. 659 660 Args: 661 affected_urls: A list of strings containing URLs to output. 662 orphaned_urls: A list of strings containing URLs to output as closable. 663 file_handle: A file handle to write the string to. Defaults to stdout. 664 auto_close_bugs: A boolean specifying whether bugs in |orphaned_urls| should 665 be auto-closed on CL submission or not. If not closed, a comment will 666 be posted instead. 667 """ 668 669 def AddBugTypeToOutputString(urls, prefix): 670 output_str = '' 671 current_line = '' 672 bugs_on_line = 0 673 674 urls = collections.deque(urls) 675 676 while len(urls): 677 current_bug = urls.popleft() 678 current_bug = current_bug.split('crbug.com/', 1)[1] 679 # Handles cases like crbug.com/angleproject/1234. 680 current_bug = current_bug.replace('/', ':') 681 682 # First bug on the line. 683 if not current_line: 684 current_line = '%s %s' % (prefix, current_bug) 685 # Bug or length limit hit for line. 686 elif ( 687 len(current_line) + len(current_bug) + 2 > MAX_CHARACTERS_PER_CL_LINE 688 or bugs_on_line >= MAX_BUGS_PER_LINE): 689 output_str += current_line + '\n' 690 bugs_on_line = 0 691 current_line = '%s %s' % (prefix, current_bug) 692 # Can add to current line. 693 else: 694 current_line += ', %s' % current_bug 695 696 bugs_on_line += 1 697 698 output_str += current_line + '\n' 699 return output_str 700 701 file_handle = file_handle or sys.stdout 702 affected_but_not_closable = set(affected_urls) - set(orphaned_urls) 703 affected_but_not_closable = list(affected_but_not_closable) 704 affected_but_not_closable.sort() 705 706 output_str = '' 707 if affected_but_not_closable: 708 output_str += AddBugTypeToOutputString(affected_but_not_closable, 'Bug:') 709 if orphaned_urls: 710 if auto_close_bugs: 711 output_str += AddBugTypeToOutputString(orphaned_urls, 'Fixed:') 712 else: 713 output_str += AddBugTypeToOutputString(orphaned_urls, 'Bug:') 714 _PostCommentsToOrphanedBugs(orphaned_urls) 715 716 file_handle.write('Affected bugs for CL description:\n%s' % output_str) 717 718 719def _PostCommentsToOrphanedBugs(orphaned_urls: List[str]) -> None: 720 """Posts comments to bugs in |orphaned_urls| saying they can likely be closed. 721 722 Does not post again if the comment has been posted before in the past. 723 724 Args: 725 orphaned_urls: A list of strings containing URLs to post comments to. 726 """ 727 728 try: 729 buganizer_client = _GetBuganizerClient() 730 except buganizer.BuganizerError as e: 731 logging.error( 732 'Encountered error when authenticating, cannot post comments. %s', e) 733 return 734 735 for url in orphaned_urls: 736 try: 737 comment_list = buganizer_client.GetIssueComments(url) 738 existing_comments = [c['comment'] for c in comment_list] 739 if BUGANIZER_COMMENT not in existing_comments: 740 buganizer_client.NewComment(url, BUGANIZER_COMMENT) 741 except buganizer.BuganizerError: 742 logging.exception('Could not fetch or add comments for %s', url) 743 744 745def _GetBuganizerClient() -> buganizer.BuganizerClient: 746 """Helper function to get a usable Buganizer client.""" 747 return buganizer.BuganizerClient() 748