xref: /aosp_15_r20/external/cronet/testing/unexpected_passes_common/result_output.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
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