• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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