pw_presubmit: Allow exiting early if failing in CI

Bug: b/295020927
Change-Id: I1416ffd168042c3c73a74a5c588cbe184614fd06
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/164371
Reviewed-by: Taylor Cramer <cramertj@google.com>
Pigweed-Auto-Submit: Rob Mohr <mohrr@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed-service-accounts.iam.gserviceaccount.com>
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/recipes/pw_presubmit.expected/one_step_exit_early.json b/recipes/pw_presubmit.expected/one_step_exit_early.json
new file mode 100644
index 0000000..a298564
--- /dev/null
+++ b/recipes/pw_presubmit.expected/one_step_exit_early.json
@@ -0,0 +1,101 @@
+[
+  {
+    "cmd": [],
+    "name": "fetch project cr-buildbucket.cfg"
+  },
+  {
+    "cmd": [
+      "luci-auth",
+      "token",
+      "-lifetime",
+      "3m"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "project:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "fetch project cr-buildbucket.cfg.get access token for default account",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::url]/resources/pycurl.py",
+      "--url",
+      "https://luci-config.appspot.com/_ah/api/config/v1/config_sets/projects/project/config/cr-buildbucket.cfg",
+      "--status-json",
+      "/path/to/tmp/json",
+      "--outfile",
+      "/path/to/tmp/json",
+      "--headers-json",
+      "{\"Authorization\": \"Bearer extra.secret.token.should.not.be.logged\"}"
+    ],
+    "luci_context": {
+      "realm": {
+        "name": "project:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "fetch project cr-buildbucket.cfg.get",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "bb",
+      "ls",
+      "-host",
+      "cr-buildbucket.appspot.com",
+      "-json",
+      "-nopage",
+      "-n",
+      "10",
+      "-fields",
+      "status",
+      "-predicate",
+      "{\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}}"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "project:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "buildbucket.search",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@raw_io.output_text@{\"status\": \"FAILURE\"}@@@",
+      "@@@STEP_LOG_END@raw_io.output_text@@@",
+      "@@@STEP_LINK@0@https://cr-buildbucket.appspot.com/build/8945511751514863184@@@"
+    ]
+  },
+  {
+    "name": "$result",
+    "summaryMarkdown": "Exiting early since [project/ci/builder](https://ci.chromium.org/ui/p/project/builders/ci/builder) has not recently passed."
+  }
+]
\ No newline at end of file
diff --git a/recipes/pw_presubmit.expected/one_step.json b/recipes/pw_presubmit.expected/one_step_no_exit.json
similarity index 96%
rename from recipes/pw_presubmit.expected/one_step.json
rename to recipes/pw_presubmit.expected/one_step_no_exit.json
index b7d0266..794a2ff 100644
--- a/recipes/pw_presubmit.expected/one_step.json
+++ b/recipes/pw_presubmit.expected/one_step_no_exit.json
@@ -1,6 +1,101 @@
 [
   {
     "cmd": [],
+    "name": "fetch project cr-buildbucket.cfg"
+  },
+  {
+    "cmd": [
+      "luci-auth",
+      "token",
+      "-lifetime",
+      "3m"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "project:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "fetch project cr-buildbucket.cfg.get access token for default account",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::url]/resources/pycurl.py",
+      "--url",
+      "https://luci-config.appspot.com/_ah/api/config/v1/config_sets/projects/project/config/cr-buildbucket.cfg",
+      "--status-json",
+      "/path/to/tmp/json",
+      "--outfile",
+      "/path/to/tmp/json",
+      "--headers-json",
+      "{\"Authorization\": \"Bearer extra.secret.token.should.not.be.logged\"}"
+    ],
+    "luci_context": {
+      "realm": {
+        "name": "project:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "fetch project cr-buildbucket.cfg.get",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "bb",
+      "ls",
+      "-host",
+      "cr-buildbucket.appspot.com",
+      "-json",
+      "-nopage",
+      "-n",
+      "10",
+      "-fields",
+      "status",
+      "-predicate",
+      "{\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}}"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "project:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "buildbucket.search",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@raw_io.output_text@{\"status\": \"SUCCESS\"}@@@",
+      "@@@STEP_LOG_END@raw_io.output_text@@@",
+      "@@@STEP_LINK@0@https://cr-buildbucket.appspot.com/build/8945511751514863184@@@"
+    ]
+  },
+  {
+    "cmd": [],
     "name": "checkout pigweed",
     "~followup_annotations": [
       "@@@STEP_LINK@applied pigweed:123456@https://pigweed-review.googlesource.com/c/123456@@@"
diff --git a/recipes/pw_presubmit.proto b/recipes/pw_presubmit.proto
index d3efcf2..1124848 100644
--- a/recipes/pw_presubmit.proto
+++ b/recipes/pw_presubmit.proto
@@ -51,4 +51,10 @@
   // then STEP_NAME_DEFAULT is interpreted as WITH_WITHOUT_STEP_NAME. When there
   // are multiple steps it's interpreted as ONLY_WITH_STEP_NAME.
   StepName metadata_step_name_usage = 6;
+
+  // If the corresponding builder is persistently failing in CI, then have this
+  // builder exit immediately with a passing status. This way failing builders
+  // don't block continuing development. Only applies to TRY builders launched
+  // by CV.
+  bool exit_tryjob_early_if_failing_in_ci = 7;
 }
diff --git a/recipes/pw_presubmit.py b/recipes/pw_presubmit.py
index 62ced87..4701158 100644
--- a/recipes/pw_presubmit.py
+++ b/recipes/pw_presubmit.py
@@ -14,17 +14,22 @@
 """Recipe for testing Pigweed using presubmit_checks.py script."""
 
 import datetime
+import re
 
 from PB.go.chromium.org.luci.buildbucket.proto import common
 from PB.recipes.pigweed.pw_presubmit import InputProperties, StepName
 from PB.recipe_engine import result
 
 DEPS = [
+    'fuchsia/buildbucket_util',
+    'fuchsia/builder_status',
     'fuchsia/gsutil',
     'pigweed/checkout',
     'pigweed/environment',
     'pigweed/pw_presubmit',
     'pigweed/util',
+    'recipe_engine/buildbucket',
+    'recipe_engine/cq',
     'recipe_engine/file',
     'recipe_engine/futures',
     'recipe_engine/json',
@@ -65,6 +70,23 @@
     """Run Pigweed presubmit checks."""
     gcs_bucket = props.gcs_bucket
 
+    if (
+        api.buildbucket_util.is_tryjob and
+        api.cq.active and
+        props.exit_tryjob_early_if_failing_in_ci
+    ):
+        bucket = re.sub(r'\btry\b', 'ci', api.buildbucket.build.builder.bucket)
+        status = api.builder_status.retrieve(bucket=bucket)
+        if not api.builder_status.has_recently_passed(status):
+            full_name = '/'.join((status.project, bucket, status.builder))
+            return result.RawResult(
+                summary_markdown=(
+                    f'Exiting early since [{full_name}]({status.link}) has not '
+                    'recently passed.'
+                ),
+                status=common.SUCCESS,
+            )
+
     checkout = api.checkout(props.checkout_options)
     env = api.environment.init(checkout, props.environment_options)
 
@@ -273,6 +295,7 @@
 
     def properties(
         *,
+        exit_tryjob_early_if_failing_in_ci=False,
         extensions_to_sign=('.out',),
         gcs_bucket=None,
         metadata_step_name_usage=None,
@@ -286,14 +309,31 @@
                 metadata_step_name_usage
             )
         props.extensions_to_sign.extend(extensions_to_sign)
+        props.exit_tryjob_early_if_failing_in_ci = (
+            exit_tryjob_early_if_failing_in_ci
+        )
         if gcs_bucket:
             props.gcs_bucket = gcs_bucket
         return api.properties(props)
 
     yield (
-        api.test('one_step')
-        + properties(step=['step1'])
+        api.test('one_step_no_exit')
+        + properties(step=['step1'], exit_tryjob_early_if_failing_in_ci=True)
         + api.checkout.try_test_data()
+        + api.cq(run_mode=api.cq.DRY_RUN)
+        + api.buildbucket.simulated_search_results(
+            [api.builder_status.passed()]
+        )
+    )
+
+    yield (
+        api.test('one_step_exit_early')
+        + properties(step=['step1'], exit_tryjob_early_if_failing_in_ci=True)
+        + api.checkout.try_test_data()
+        + api.cq(run_mode=api.cq.DRY_RUN)
+        + api.buildbucket.simulated_search_results(
+            [api.builder_status.failure()]
+        )
     )
 
     yield (