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