suppressFailingTests now using bug data as source of truth

This makes it easier to ensure all of these bugs have their tests suppressed per the latest instructions at go/androidx/triage_monitor.

Also, because the bugs themselves list the test failure urls, this script no longer puts the test failure urls into the source code as additional, uncommitted context.

Also, because we don't need to have additional, uncommitted context, this script now also splits the commits based on OWNERS files, to give the chance to have different reviewers per project

Bug: 190196333

Change-Id: I8366bdc229d03a5159a6b72ac05df7ba0520f08e
diff --git a/development/suppressFailingTests.py b/development/suppressFailingTests.py
index 0e38902..d9c71af 100755
--- a/development/suppressFailingTests.py
+++ b/development/suppressFailingTests.py
@@ -11,27 +11,41 @@
 parser = argparse.ArgumentParser(
   description=__doc__
 )
-parser.add_argument("config_path", help="Path of file to process, downloaded from go/androidx-test-failures", nargs="+")
+parser.add_argument("-v", help="Verbose", action="store_true")
 
 dirOfThisScript = os.path.dirname(os.path.realpath(__file__))
 supportRoot = os.path.dirname(dirOfThisScript)
 
+logger = None
+
+class PrintLogger(object):
+  def log(self, message):
+    print(message)
+
+class DisabledLogger(object):
+  def log(self, message):
+    pass
+
+def log(message):
+  logger.log(message)
+
 class LocatedFailure(object):
-  def __init__(self, failure, location):
+  def __init__(self, failure, location, bugId):
     self.failure = failure
     self.location = location
+    self.bugId = bugId
 
 class TestFailure(object):
-  def __init__(self, qualifiedClassName, methodName, testDefinitionName, consistent, branchName, testResultId):
+  def __init__(self, qualifiedClassName, methodName, testDefinitionName, branchName, testFailureUrl, bugId):
     self.qualifiedClassName = qualifiedClassName
     self.methodName = methodName
     self.testDefinitionName = testDefinitionName
-    self.consistent = consistent
     self.branchName = branchName
-    self.testResultId = testResultId
+    self.failureUrl = testFailureUrl
+    self.bugId = bugId
 
   def getUrl(self):
-    return "https://android-build.googleplex.com/builds/tests/view?testResultId=" + self.testResultId
+    return self.testFailureUrl
 
 class FailuresDatabase(object):
   """A collection of LocatedFailure instances, organized by their locations"""
@@ -47,11 +61,6 @@
     lineNumber = locatedFailure.location.lineNumber
     if lineNumber not in failuresAtPath:
       failuresAtPath[lineNumber] = locatedFailure
-    else:
-      # already have a failure at this location
-      if not failuresAtPath[lineNumber].failure.consistent:
-        # if the previously detected failure wasn't consistent, update with the new one
-        failuresAtPath[lineNumber] = locatedFailure
 
   # returns Map<String, LocatedFailure> with key being filePath
   def getAll(self):
@@ -65,39 +74,78 @@
       results[path] = resultsAtPath
     return results
 
-# parses the data file containing the failing tests
-def parse():
-  arguments = parser.parse_args()
-  configPath = arguments.config_path[0]
+def parseBugLine(bugId, line):
+  components = line.split(" | ")
+  if len(components) < 3:
+    return None
+  testLink = components[1]
+  # Example test link: [compose-ui-uidebugAndroidTest.xml androidx.compose.ui.window.PopupAlignmentTest#popup_correctPosition_alignmentTopCenter_rtl](https://android-build.googleplex.com/builds/tests/view?testResultId=TR96929024659298098)
+  closeBracketIndex = testLink.rindex("]")
+  if closeBracketIndex <= 0:
+    raise Exception("Failed to parse b/" + bugId + " '" + line + "', testLink '" + testLink + "', closeBracketIndex = " + str(closeBracketIndex))
+  linkText = testLink[1:closeBracketIndex]
+  linkDest = testLink[closeBracketIndex + 1:]
+  # Example linkText: compose-ui-uidebugAndroidTest.xml androidx.compose.ui.window.PopupAlignmentTest#popup_correctPosition_alignmentTopCenter_rtl
+  # Example linkDest: (https://android-build.googleplex.com/builds/tests/view?testResultId=TR96929024659298098)
+  testResultUrl = linkDest.replace("(", "").replace(")", "")
+  # Example testResultUrl: https://android-build.googleplex.com/builds/tests/view?testResultId=TR96929024659298098
+  spaceIndex = linkText.index(" ")
+  if spaceIndex <= 0:
+    raise Exception("Failed to parse b/" + bugId + " '" + line + "', linkText = '" + linkText + ", spaceIndex = " + str(spaceIndex))
+  testDefinitionName = linkText[:spaceIndex]
+  testPath = linkText[spaceIndex+1:]
+  # Example test path: androidx.compose.ui.window.PopupAlignmentTest#popup_correctPosition_alignmentTopCenter_rtl
+  testPathSplit = testPath.split("#")
+  if len(testPathSplit) != 2:
+    raise Exception("Failed to parse b/" + bugId + " '" + line + "', testPath = '" + testPath + "', len(testPathSplit) = " + str(len(testPathSplit)))
+  testClass, testMethod = testPathSplit
+
+  branchName = components[2].strip()
+  print("  parsed test failure class=" + testClass + " method='" + testMethod + "' definition=" + testDefinitionName + " branch=" + branchName + " failureUrl=" + testResultUrl + " bugId=" + bugId)
+  return TestFailure(testClass, testMethod, testDefinitionName, branchName, testResultUrl, bugId)
+
+def parseBug(bugId):
+  bugText = shellRunner.runAndGetOutput(["bugged", "show", bugId])
+  log("bug text = '" + bugText + "'")
   failures = []
-  with open(configPath) as configFile:
-    config = csv.DictReader(configFile, delimiter="\t")
-    for item in config:
-      # Decide whether this failure appears to be happening reliably (consistent = True)
-      # or flakily (consistent = False).
-      #
-      # A flaky failure will probably occur a small number (probably 1) of times in a row
-      # and a small fraction of times (slightly more than 0%),
-      #
-      # whereas a consistent failure will probably occur a large number of times (until we address
-      # it, probably at least 3) and about 100% of the time
-      #
-      # These cutoff values are mostly arbitrary, about halfway between the expectations for these
-      # two types of failures
-      if int(item["consecutive_failures"]) >= 2 and float(item["failure_rate"]) > 0.5:
-        consistent = True
-      else:
-        consistent = False
-      failures.append(
-        TestFailure(
-          item["test_class"],
-          item["method"],
-          item["test_definition_name"],
-          consistent,
-          item["branch_name"],
-          item["test_result_id"]
-        )
-      )
+  bugLines = bugText.split("\n")
+
+  stillFailing = True
+  listingTests = False
+  for i in range(len(bugLines)):
+    line = bugLines[i]
+    #log("parsing bug line " + line)
+    if listingTests:
+      failure = parseBugLine(bugId, line)
+      if failure is not None:
+        failures.append(failure)
+    if "---|---|---|---|---" in line: # table start
+      listingTests = True
+    if " # " in line: # start of new section
+      listingTests = False
+    if "There are no more failing tests in this regression" in line or "ATN has not seen a failure for this regression recently." in line or "This regression has been identified as a duplicate of another one" in line:
+      stillFailing = False
+  if len(failures) < 1:
+    raise Exception("Failed to parse b/" + bugId + ": identified 0 failures. Rerun with -v for more information")
+  if not stillFailing:
+    print("tests no longer failing")
+    return []
+  return failures
+
+# identifies failing tests
+def getFailureData():
+  bugsQuery = ["bugged", "search", "hotlistid:5083126 status:open", "--columns", "issue"]
+  print("Searching for bugs: " + str(bugsQuery))
+  bugsOutput = shellRunner.runAndGetOutput(bugsQuery)
+  bugIds = bugsOutput.split("\n")
+  print("Checking " + str(len(bugIds)) + " bugs")
+  failures = []
+  for i in range(len(bugIds)):
+    bugId = bugIds[i].strip()
+    if bugId != "issue" and bugId != "":
+      print("")
+      print("Parsing bug " + bugId + " (" + str(i) + "/" + str(len(bugIds)) + ")")
+      failures += parseBug(bugId)
   return failures
 
 class FileLocation(object):
@@ -315,131 +363,68 @@
   def save(self):
     writeFile(self.path, "\n".join(self.lines))
 
-# searches for bugs matching certain criteria, using the `bugged` CLI tool
-class BugFinder(object):
-  def __init__(self):
-    self.bugsByQuery = {}
-
-  def findForFailure(self, testFailure):
-    qualifiedName = testFailure.qualifiedClassName
-    text = ["title:" + qualifiedName, "status:open", "--columns=issue"]
-    return self.query(text)
-
-  def query(self, args):
-    text = " ".join(args)
-    if text not in self.bugsByQuery:
-      response = None
-      try:
-        response = shellRunner.runAndGetOutput(["bugged", "search"] + args)
-      except FileNotFoundError as e:
-        raise FileNotFoundError("The `bugged` command-line tool is required but was not found. See go/bugged to install.")
-      lines = response.split("\n")
-      result = None
-      for line in response.split("\n"):
-        if line != "issue":
-          result = line
-          break
-      if result == "":
-        result = None
-      self.bugsByQuery[text] = result
-    return self.bugsByQuery[text]
-
-bugFinder = BugFinder()
-
 # converts from a List<TestFailure> to a FailuresDatabase containing LocatedFailure
 def locate(failures):
   db = FailuresDatabase()
   for failure in failures:
     location = classFinder.findMethod(failure.qualifiedClassName, failure.methodName)
     if location is not None:
-      db.add(LocatedFailure(failure, location))
+      db.add(LocatedFailure(failure, location, failure.bugId))
     else:
-      message = "Could not locate " + str(failure.qualifiedClassName) + "#" + str(failure.methodName)
+      message = "Could not locate " + str(failure.qualifiedClassName) + "#" + str(failure.methodName) + " for " + str(failure.bugId)
       if failure.branchName != "aosp-androidx-main":
         message += ", should be in " + failure.branchName
       print(message)
   return db
 
-# removes test result urls from the commit
-def uncommitTestResultUrls():
-  # first, remove test results urls from the files
-  shellRunner.run(["bash", "-c", "git log -1 --name-only | grep -v ' ' | xargs sed -i 's| // .*testResultId.*||g'"])
-  # commit the removal of these test result urls
-  shellRunner.run(["git", "add", "."])
-  shellRunner.run(["git", "commit", "-q", "--amend", "--no-edit"])
-  # restore the previous versions of the files
-  shellRunner.run(["git", "checkout", "-q", "HEAD@{1}", "--", "."])
-  shellRunner.run(["git", "reset", "-q"])
-
 # Given a FailureDatabase, disables all of the tests mentioned in it, by adding the appropriate
 # annotations:
-#  consistent failures get annotated with @Ignore ,
-#  flaky failures get annotated with @FlakyTest.
+#  failures get annotated with @Ignore ,
 # Annotations link to the associated bug if possible
 def disable(failuresDatabase):
-  mentionedBugs = set()
   numUpdates = 0
   failuresByPath = failuresDatabase.getAll()
   for path, failuresAtPath in failuresByPath.items():
     source = SourceFile(path)
     addedIgnore = False
-    addedFlaky = False
     for failure in failuresAtPath:
       lineNumber = failure.location.lineNumber
-      if source.hasAnnotation(lineNumber, "@FlakyTest") or source.hasAnnotation(lineNumber, "@Ignore"):
+      if source.hasAnnotation(lineNumber, "@Ignore"):
         continue
-      bug = bugFinder.findForFailure(failure.failure)
-      if bug is not None:
-        mentionedBugs.add(bug)
-      if failure.failure.consistent:
-        if bug is not None:
-          bugText = '"b/' + bug + '"'
-        else:
-          bugText = '"why"'
-        source.addAnnotation(lineNumber, "@Ignore(" + bugText + ") // " + failure.failure.getUrl())
-        addedIgnore = True
-      else:
-        if bug is not None:
-          bugText = "bugId = " + bug
-        else:
-          bugText = "bugId = num"
-        source.addAnnotation(lineNumber, "@FlakyTest(" + bugText + ") // " + failure.failure.getUrl())
-        addedFlaky = True
+      bugId = failure.bugId
+      bugText = '"b/' + bugId + '"'
+      source.addAnnotation(lineNumber, "@Ignore(" + bugText + ")")
+      addedIgnore = True
     if addedIgnore:
       source.addImport("org.junit.Ignore")
-    if addedFlaky:
-      source.addImport("androidx.test.filters.FlakyTest")
-    if addedIgnore or addedFlaky:
-      # save file
       source.save()
       numUpdates += 1
-  # make git commit
-  commitHeader = """Mostly autogenerated suppression of test failures
+  print("Made " + str(numUpdates) + " updates")
+
+def commit():
+  print("Generating git commit per OWNERS file")
+  os.chdir(supportRoot)
+  commitMessage = """Autogenerated suppression of test failures
 
 This commit was created with the help of development/suppressFailingTests.py
-
 """
+  shellRunner.run(["development/split_change_into_owners.sh", commitMessage])
 
-  bugStanzas = "\n".join(["Bug: " + bug for bug in sorted(mentionedBugs)])
-  commitMessage = commitHeader + bugStanzas
-
-  # make git commit containing the suppressions
-  os.chdir(supportRoot)
-  shellRunner.run(["git", "add", "."])
-  shellRunner.run(["git", "commit", "-q", "--no-edit", "-m", commitMessage])
-
-  # Remove test result urls from the git commit but keep them in the tree
-  uncommitTestResultUrls()
-  print("")
-  print("Committed updates to " + str(numUpdates) + " files. Inspect/fix as needed.")
-  print("")
-  print("Additional context (test failure urls) has been added but not committed.")
-  print("You can manually remove this information or you can run `git checkout -- <path>` to discard uncommitted changes under <path>")
 
 def main():
-  failures = parse()
+  global logger
+  arguments = parser.parse_args()
+  if arguments.v:
+    logger = PrintLogger()
+  else:
+    logger = DisabledLogger()
+  failures = getFailureData()
+  if len(failures) < 1:
+    print("Found 0 failures")
+    return
   locations = locate(failures)
   disable(locations)
+  commit()
 
 if __name__ == "__main__":
   main()