Move build bisection code to its own module

Change-Id: I76f0ffcaa90cc9582940671189bce87fe9185f19
Reviewed-on: https://dart-review.googlesource.com/c/recipes/+/138505
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/README.recipes.md b/README.recipes.md
index b292568..8cff48c 100644
--- a/README.recipes.md
+++ b/README.recipes.md
@@ -3,9 +3,11 @@
 ## Table of Contents
 
 **[Recipe Modules](#Recipe-Modules)**
+  * [bisect_build](#recipe_modules-bisect_build)
   * [dart](#recipe_modules-dart)
 
 **[Recipes](#Recipes)**
+  * [bisect_build:tests/tests](#recipes-bisect_build_tests_tests)
   * [dart/chocolatey](#recipes-dart_chocolatey)
   * [dart/docker](#recipes-dart_docker)
   * [dart/external](#recipes-dart_external)
@@ -19,6 +21,15 @@
   * [dart:examples/example-get_secret](#recipes-dart_examples_example-get_secret)
 ## Recipe Modules
 
+### *recipe_modules* / [bisect\_build](/recipe_modules/bisect_build)
+
+[DEPS](/recipe_modules/bisect_build/__init__.py#5): [depot\_tools/gitiles][depot_tools/recipe_modules/gitiles], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+
+#### **class [BisectApi](/recipe_modules/bisect_build/api.py#11)([RecipeApi][recipe_engine/wkt/RecipeApi]):**
+
+&mdash; **def [is\_bisecting](/recipe_modules/bisect_build/api.py#16)(self):**
+
+&mdash; **def [schedule](/recipe_modules/bisect_build/api.py#19)(self, repo_url, reason):**
 ### *recipe_modules* / [dart](/recipe_modules/dart)
 
 [DEPS](/recipe_modules/dart/__init__.py#5): [build/goma][build/recipe_modules/goma], [build/swarming\_client][build/recipe_modules/swarming_client], [depot\_tools/bot\_update][depot_tools/recipe_modules/bot_update], [depot\_tools/depot\_tools][depot_tools/recipe_modules/depot_tools], [depot\_tools/gclient][depot_tools/recipe_modules/gclient], [depot\_tools/gerrit][depot_tools/recipe_modules/gerrit], [depot\_tools/git][depot_tools/recipe_modules/git], [depot\_tools/gsutil][depot_tools/recipe_modules/gsutil], [depot\_tools/tryserver][depot_tools/recipe_modules/tryserver], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/cipd][recipe_engine/recipe_modules/cipd], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/file][recipe_engine/recipe_modules/file], [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/platform][recipe_engine/recipe_modules/platform], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/python][recipe_engine/recipe_modules/python], [recipe\_engine/raw\_io][recipe_engine/recipe_modules/raw_io], [recipe\_engine/runtime][recipe_engine/recipe_modules/runtime], [recipe\_engine/service\_account][recipe_engine/recipe_modules/service_account], [recipe\_engine/step][recipe_engine/recipe_modules/step], [recipe\_engine/swarming][recipe_engine/recipe_modules/swarming]
@@ -37,7 +48,7 @@
 
 Checks out the dart code and prepares it for building.
 
-&mdash; **def [collect\_all](/recipe_modules/dart/api.py#282)(self, steps):**
+&mdash; **def [collect\_all](/recipe_modules/dart/api.py#283)(self, steps):**
 
 Collects the results of a sharded test run.
 
@@ -49,11 +60,11 @@
 
 Returns the path to the checked-in SDK dart executable.
 
-&mdash; **def [delete\_debug\_log](/recipe_modules/dart/api.py#564)(self):**
+&mdash; **def [delete\_debug\_log](/recipe_modules/dart/api.py#562)(self):**
 
 Deletes the debug log file
 
-&mdash; **def [download\_browser](/recipe_modules/dart/api.py#835)(self, runtime, version):**
+&mdash; **def [download\_browser](/recipe_modules/dart/api.py#833)(self, runtime, version):**
 
 &mdash; **def [download\_parent\_isolate](/recipe_modules/dart/api.py#155)(self):**
 
@@ -65,7 +76,7 @@
 
 Kills leftover tasks from previous runs or steps.
 
-&mdash; **def [read\_debug\_log](/recipe_modules/dart/api.py#552)(self):**
+&mdash; **def [read\_debug\_log](/recipe_modules/dart/api.py#550)(self):**
 
 Reads the debug log file
 
@@ -74,7 +85,7 @@
 Runs test.py in the given isolate, sharded over several swarming tasks.
 Returns the created tasks, which can be collected with collect_all().
 
-&mdash; **def [test](/recipe_modules/dart/api.py#570)(self, test_data):**
+&mdash; **def [test](/recipe_modules/dart/api.py#568)(self, test_data):**
 
 Reads the test-matrix.json file in checkout and runs each step listed
 in the file.
@@ -84,6 +95,11 @@
 Builds an isolate
 ## Recipes
 
+### *recipes* / [bisect\_build:tests/tests](/recipe_modules/bisect_build/tests/tests.py)
+
+[DEPS](/recipe_modules/bisect_build/tests/tests.py#8): [bisect\_build](#recipe_modules-bisect_build), [depot\_tools/gitiles][depot_tools/recipe_modules/gitiles], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+
+&mdash; **def [RunSteps](/recipe_modules/bisect_build/tests/tests.py#25)(api, current_failure):**
 ### *recipes* / [dart/chocolatey](/recipes/dart/chocolatey.py)
 
 [DEPS](/recipes/dart/chocolatey.py#7): [dart](#recipe_modules-dart), [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/file][recipe_engine/recipe_modules/file], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/platform][recipe_engine/recipe_modules/platform], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/step][recipe_engine/recipe_modules/step], [recipe\_engine/url][recipe_engine/recipe_modules/url]
@@ -101,39 +117,39 @@
 &mdash; **def [RunSteps](/recipes/dart/external.py#13)(api):**
 ### *recipes* / [dart/flutter\_engine](/recipes/dart/flutter_engine.py)
 
-[DEPS](/recipes/dart/flutter_engine.py#15): [build/goma][build/recipe_modules/goma], [dart](#recipe_modules-dart), [depot\_tools/bot\_update][depot_tools/recipe_modules/bot_update], [depot\_tools/depot\_tools][depot_tools/recipe_modules/depot_tools], [depot\_tools/gclient][depot_tools/recipe_modules/gclient], [depot\_tools/gitiles][depot_tools/recipe_modules/gitiles], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/file][recipe_engine/recipe_modules/file], [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/platform][recipe_engine/recipe_modules/platform], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/python][recipe_engine/recipe_modules/python], [recipe\_engine/runtime][recipe_engine/recipe_modules/runtime], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+[DEPS](/recipes/dart/flutter_engine.py#11): [build/goma][build/recipe_modules/goma], [bisect\_build](#recipe_modules-bisect_build), [dart](#recipe_modules-dart), [depot\_tools/bot\_update][depot_tools/recipe_modules/bot_update], [depot\_tools/depot\_tools][depot_tools/recipe_modules/depot_tools], [depot\_tools/gclient][depot_tools/recipe_modules/gclient], [depot\_tools/gitiles][depot_tools/recipe_modules/gitiles], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/file][recipe_engine/recipe_modules/file], [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/platform][recipe_engine/recipe_modules/platform], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/python][recipe_engine/recipe_modules/python], [recipe\_engine/runtime][recipe_engine/recipe_modules/runtime], [recipe\_engine/step][recipe_engine/recipe_modules/step]
 
-&mdash; **def [AnalyzeDartUI](/recipes/dart/flutter_engine.py#72)(api, checkout_dir):**
+&mdash; **def [AnalyzeDartUI](/recipes/dart/flutter_engine.py#69)(api, checkout_dir):**
 
-&mdash; **def [Build](/recipes/dart/flutter_engine.py#55)(api, checkout_dir, config, \*targets):**
+&mdash; **def [Build](/recipes/dart/flutter_engine.py#52)(api, checkout_dir, config, \*targets):**
 
-&mdash; **def [BuildAndTest](/recipes/dart/flutter_engine.py#351)(api, start_dir, checkout_dir, flutter_rev):**
+&mdash; **def [BuildAndTest](/recipes/dart/flutter_engine.py#339)(api, start_dir, checkout_dir, flutter_rev):**
 
-&mdash; **def [BuildLinux](/recipes/dart/flutter_engine.py#107)(api, checkout_dir):**
+&mdash; **def [BuildLinux](/recipes/dart/flutter_engine.py#104)(api, checkout_dir):**
 
-&mdash; **def [BuildLinuxAndroidArm](/recipes/dart/flutter_engine.py#89)(api, checkout_dir):**
+&mdash; **def [BuildLinuxAndroidArm](/recipes/dart/flutter_engine.py#86)(api, checkout_dir):**
 
-&mdash; **def [BuildLinuxAndroidx86](/recipes/dart/flutter_engine.py#82)(api, checkout_dir):**
+&mdash; **def [BuildLinuxAndroidx86](/recipes/dart/flutter_engine.py#79)(api, checkout_dir):**
 
-&mdash; **def [CopyArtifacts](/recipes/dart/flutter_engine.py#179)(api, engine_src, cached_dest, file_paths):**
+&mdash; **def [CopyArtifacts](/recipes/dart/flutter_engine.py#176)(api, engine_src, cached_dest, file_paths):**
 
-&mdash; **def [GetCheckout](/recipes/dart/flutter_engine.py#130)(api):**
+&mdash; **def [GetCheckout](/recipes/dart/flutter_engine.py#127)(api):**
 
-&mdash; **def [KillTasks](/recipes/dart/flutter_engine.py#47)(api, checkout_dir, ok_ret='any'):**
+&mdash; **def [KillTasks](/recipes/dart/flutter_engine.py#44)(api, checkout_dir, ok_ret='any'):**
 
 Kills leftover tasks from previous runs or steps.
 
-&mdash; **def [RunGN](/recipes/dart/flutter_engine.py#64)(api, checkout_dir, \*args):**
+&mdash; **def [RunGN](/recipes/dart/flutter_engine.py#61)(api, checkout_dir, \*args):**
 
-&mdash; **def [RunSteps](/recipes/dart/flutter_engine.py#312)(api):**
+&mdash; **def [RunSteps](/recipes/dart/flutter_engine.py#309)(api):**
 
-&mdash; **def [TestEngine](/recipes/dart/flutter_engine.py#77)(api, checkout_dir):**
+&mdash; **def [TestEngine](/recipes/dart/flutter_engine.py#74)(api, checkout_dir):**
 
-&mdash; **def [TestFlutter](/recipes/dart/flutter_engine.py#266)(api, start_dir, just_built_dart_sdk):**
+&mdash; **def [TestFlutter](/recipes/dart/flutter_engine.py#263)(api, start_dir, just_built_dart_sdk):**
 
-&mdash; **def [TestObservatory](/recipes/dart/flutter_engine.py#119)(api, checkout_dir):**
+&mdash; **def [TestObservatory](/recipes/dart/flutter_engine.py#116)(api, checkout_dir):**
 
-&mdash; **def [UpdateCachedEngineArtifacts](/recipes/dart/flutter_engine.py#192)(api, flutter, engine_src):**
+&mdash; **def [UpdateCachedEngineArtifacts](/recipes/dart/flutter_engine.py#189)(api, flutter, engine_src):**
 ### *recipes* / [dart/forward\_branch](/recipes/dart/forward_branch.py)
 
 [DEPS](/recipes/dart/forward_branch.py#8): [dart](#recipe_modules-dart), [depot\_tools/git][depot_tools/recipe_modules/git], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/raw\_io][recipe_engine/recipe_modules/raw_io], [recipe\_engine/step][recipe_engine/recipe_modules/step]
diff --git a/recipe_modules/bisect_build/__init__.py b/recipe_modules/bisect_build/__init__.py
new file mode 100644
index 0000000..72028cb
--- /dev/null
+++ b/recipe_modules/bisect_build/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+# for details. All rights reserved. Use of this source code is governed by a
+# BSD-style license that can be found in the LICENSE file.
+
+DEPS = [
+    'depot_tools/gitiles',
+    'recipe_engine/buildbucket',
+    'recipe_engine/properties',
+    'recipe_engine/step',
+]
diff --git a/recipe_modules/bisect_build/api.py b/recipe_modules/bisect_build/api.py
new file mode 100644
index 0000000..cdf26eb
--- /dev/null
+++ b/recipe_modules/bisect_build/api.py
@@ -0,0 +1,102 @@
+# Copyright (c) 2020, the Dart project authors. All rights reserved. Use of this
+# source code is governed by a BSD-style license that can be found in the
+# LICENSE file.
+
+from recipe_engine import recipe_api
+
+from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
+from PB.go.chromium.org.luci.buildbucket.proto import rpc as rpc_pb2
+
+
+class BisectApi(recipe_api.RecipeApi):
+
+  def _get_reason(self):
+    return self.m.properties['bisect_reason']
+
+  def is_bisecting(self):
+    return 'bisect_reason' in self.m.properties
+
+  def schedule(self, repo_url, reason):
+    if self.is_bisecting():
+      bisect_reason = self._get_reason()
+      if bisect_reason == reason:
+        self._bisect_older(bisect_reason)
+      else:
+        self._bisect_newer(bisect_reason)
+        # The build failed for a different reason, fan out to find the root
+        # cause of that failure as well.
+        self._bisect_older(reason)
+    else:
+      self._start_bisection(repo_url, reason)
+
+  def _start_bisection(self, repo_url, reason):
+    current_rev = self.m.buildbucket.gitiles_commit.id
+    if not current_rev:
+      return
+
+    # Search for previous builds created by the Luci scheduler (to exclude
+    # bisection builds). The TimeRange includes all builds from start_time
+    # (defaults to 0) to end_time (exclusive). Because builds are ordered by
+    # create_time, the first result will be the previous build.
+    create_time = common_pb2.TimeRange(
+        end_time=self.m.buildbucket.build.create_time)
+    builder = self.m.buildbucket.build.builder
+    search_predicate = rpc_pb2.BuildPredicate(
+        builder=builder,
+        create_time=create_time,
+        tags=[common_pb2.StringPair(key='user_agent', value='luci-scheduler')])
+    result = self.m.buildbucket.search(
+        search_predicate, limit=1, step_name='fetch previous build')
+    if not result or len(result) == 0:
+      # There is no previous build: do not bisect on new builders.
+      return
+    previous_build = result[0]
+    if previous_build.status == common_pb2.FAILURE:
+      # Do not bisect if the previous build failed.
+      # TODO(athom): Check if the failure reason is the same by adding
+      #              'builds.*.summaryMarkdown' to the field mask.
+      return
+    previous_rev = previous_build.input.gitiles_commit.id
+    # We're intentionally not paging through the log to avoid bisecting an
+    # excessive number of commits.
+    commits, _ = self.m.gitiles.log(
+        url=repo_url, ref='%s..%s' % (previous_rev, current_rev))
+    commits = commits[1:]  # The first commit is the current_rev
+    commits = [commit['commit'] for commit in commits]
+    self._bisect(commits, reason)
+
+  def _bisect(self, commits, reason):
+    if len(commits) == 0:
+      # Nothing more to bisect.
+      return
+
+    middle_index = len(commits) // 2
+    newer = commits[:middle_index]
+    middle = commits[middle_index]
+    older = commits[middle_index + 1:]
+
+    commit = self.m.buildbucket.gitiles_commit
+    middle_commit = common_pb2.GitilesCommit()
+    middle_commit.CopyFrom(commit)
+    middle_commit.id = middle
+    builder = self.m.buildbucket.build.builder
+    request = self.m.buildbucket.schedule_request(
+        builder=builder.builder,
+        project=builder.project,
+        bucket=builder.bucket,
+        properties={
+            'bisect_newer': newer,
+            'bisect_older': older,
+            'bisect_reason': reason
+        },
+        gitiles_commit=middle_commit,
+        inherit_buildsets=False,
+    )
+    self.m.buildbucket.schedule([request],
+                                step_name='schedule bisect (%s)' % middle)
+
+  def _bisect_newer(self, reason):
+    self._bisect(list(self.m.properties.get('bisect_newer', [])), reason)
+
+  def _bisect_older(self, reason):
+    self._bisect(list(self.m.properties.get('bisect_older', [])), reason)
diff --git a/recipe_modules/bisect_build/tests/tests.expected/basic.json b/recipe_modules/bisect_build/tests/tests.expected/basic.json
new file mode 100644
index 0000000..7647f3f
--- /dev/null
+++ b/recipe_modules/bisect_build/tests/tests.expected/basic.json
@@ -0,0 +1,26 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "ls",
+      "-host",
+      "cr-buildbucket.appspot.com",
+      "-json",
+      "-nopage",
+      "-n",
+      "1",
+      "-fields",
+      "builder,create_time,created_by,critical,end_time,id,input,number,output,start_time,status,update_time",
+      "-predicate",
+      "{\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"createTime\": {\"endTime\": \"2018-05-25T23:50:17Z\"}, \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}"
+    ],
+    "infra_step": true,
+    "name": "fetch previous build",
+    "~followup_annotations": [
+      "@@@STEP_LOG_END@raw_io.output_text@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/dart/flutter_engine.expected/continue-bisect-on-failure.json b/recipe_modules/bisect_build/tests/tests.expected/continue-bisect-on-failure.json
similarity index 75%
rename from recipes/dart/flutter_engine.expected/continue-bisect-on-failure.json
rename to recipe_modules/bisect_build/tests/tests.expected/continue-bisect-on-failure.json
index e88b0af..74023c0 100644
--- a/recipes/dart/flutter_engine.expected/continue-bisect-on-failure.json
+++ b/recipe_modules/bisect_build/tests/tests.expected/continue-bisect-on-failure.json
@@ -8,7 +8,7 @@
     ],
     "infra_step": true,
     "name": "schedule bisect (d)",
-    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"flutter-engine-linux\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"d\", \"project\": \"linear_sdk_flutter_engine\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"c\"], \"bisect_older\": [\"e\"], \"bisect_reason\": \"Infra Failure: Step('everything') (retcode: 1)\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
+    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"d\", \"project\": \"sdk\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"c\"], \"bisect_older\": [\"e\"], \"bisect_reason\": \"failure\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@json.output@{@@@",
       "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
@@ -16,7 +16,7 @@
       "@@@STEP_LOG_LINE@json.output@      \"scheduleBuild\": {@@@",
       "@@@STEP_LOG_LINE@json.output@        \"builder\": {@@@",
       "@@@STEP_LOG_LINE@json.output@          \"bucket\": \"ci\", @@@",
-      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"flutter-engine-linux\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"builder\", @@@",
       "@@@STEP_LOG_LINE@json.output@          \"project\": \"project\"@@@",
       "@@@STEP_LOG_LINE@json.output@        }, @@@",
       "@@@STEP_LOG_LINE@json.output@        \"id\": \"8922054662172514000\"@@@",
@@ -31,7 +31,7 @@
       "@@@STEP_LOG_LINE@request@      \"scheduleBuild\": {@@@",
       "@@@STEP_LOG_LINE@request@        \"builder\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"bucket\": \"ci\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"builder\": \"flutter-engine-linux\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": \"builder\", @@@",
       "@@@STEP_LOG_LINE@request@          \"project\": \"project\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"experimental\": \"NO\", @@@",
@@ -39,7 +39,7 @@
       "@@@STEP_LOG_LINE@request@        \"gitilesCommit\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"host\": \"dart.googlesource.com\", @@@",
       "@@@STEP_LOG_LINE@request@          \"id\": \"d\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"project\": \"linear_sdk_flutter_engine\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"project\": \"sdk\", @@@",
       "@@@STEP_LOG_LINE@request@          \"ref\": \"refs/heads/master\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"priority\": 30, @@@",
@@ -50,7 +50,7 @@
       "@@@STEP_LOG_LINE@request@          \"bisect_older\": [@@@",
       "@@@STEP_LOG_LINE@request@            \"e\"@@@",
       "@@@STEP_LOG_LINE@request@          ], @@@",
-      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"Infra Failure: Step('everything') (retcode: 1)\"@@@",
+      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"failure\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", @@@",
       "@@@STEP_LOG_LINE@request@        \"tags\": [@@@",
diff --git a/recipes/dart/flutter_engine.expected/continue-bisect-on-failure.json b/recipe_modules/bisect_build/tests/tests.expected/continue-bisect-on-success.json
similarity index 71%
copy from recipes/dart/flutter_engine.expected/continue-bisect-on-failure.json
copy to recipe_modules/bisect_build/tests/tests.expected/continue-bisect-on-success.json
index e88b0af..7e7ab86 100644
--- a/recipes/dart/flutter_engine.expected/continue-bisect-on-failure.json
+++ b/recipe_modules/bisect_build/tests/tests.expected/continue-bisect-on-success.json
@@ -7,8 +7,8 @@
       "cr-buildbucket.appspot.com"
     ],
     "infra_step": true,
-    "name": "schedule bisect (d)",
-    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"flutter-engine-linux\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"d\", \"project\": \"linear_sdk_flutter_engine\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"c\"], \"bisect_older\": [\"e\"], \"bisect_reason\": \"Infra Failure: Step('everything') (retcode: 1)\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
+    "name": "schedule bisect (b)",
+    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"b\", \"project\": \"sdk\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"a\"], \"bisect_older\": [\"c\"], \"bisect_reason\": \"failure\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@json.output@{@@@",
       "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
@@ -16,7 +16,7 @@
       "@@@STEP_LOG_LINE@json.output@      \"scheduleBuild\": {@@@",
       "@@@STEP_LOG_LINE@json.output@        \"builder\": {@@@",
       "@@@STEP_LOG_LINE@json.output@          \"bucket\": \"ci\", @@@",
-      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"flutter-engine-linux\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"builder\", @@@",
       "@@@STEP_LOG_LINE@json.output@          \"project\": \"project\"@@@",
       "@@@STEP_LOG_LINE@json.output@        }, @@@",
       "@@@STEP_LOG_LINE@json.output@        \"id\": \"8922054662172514000\"@@@",
@@ -31,26 +31,26 @@
       "@@@STEP_LOG_LINE@request@      \"scheduleBuild\": {@@@",
       "@@@STEP_LOG_LINE@request@        \"builder\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"bucket\": \"ci\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"builder\": \"flutter-engine-linux\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": \"builder\", @@@",
       "@@@STEP_LOG_LINE@request@          \"project\": \"project\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"experimental\": \"NO\", @@@",
       "@@@STEP_LOG_LINE@request@        \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", @@@",
       "@@@STEP_LOG_LINE@request@        \"gitilesCommit\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"host\": \"dart.googlesource.com\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"id\": \"d\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"project\": \"linear_sdk_flutter_engine\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"id\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"project\": \"sdk\", @@@",
       "@@@STEP_LOG_LINE@request@          \"ref\": \"refs/heads/master\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"priority\": 30, @@@",
       "@@@STEP_LOG_LINE@request@        \"properties\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"bisect_newer\": [@@@",
-      "@@@STEP_LOG_LINE@request@            \"c\"@@@",
+      "@@@STEP_LOG_LINE@request@            \"a\"@@@",
       "@@@STEP_LOG_LINE@request@          ], @@@",
       "@@@STEP_LOG_LINE@request@          \"bisect_older\": [@@@",
-      "@@@STEP_LOG_LINE@request@            \"e\"@@@",
+      "@@@STEP_LOG_LINE@request@            \"c\"@@@",
       "@@@STEP_LOG_LINE@request@          ], @@@",
-      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"Infra Failure: Step('everything') (retcode: 1)\"@@@",
+      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"failure\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", @@@",
       "@@@STEP_LOG_LINE@request@        \"tags\": [@@@",
diff --git a/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-if-previous-build-failed.json b/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-if-previous-build-failed.json
new file mode 100644
index 0000000..a6cc90b
--- /dev/null
+++ b/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-if-previous-build-failed.json
@@ -0,0 +1,28 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "ls",
+      "-host",
+      "cr-buildbucket.appspot.com",
+      "-json",
+      "-nopage",
+      "-n",
+      "1",
+      "-fields",
+      "builder,create_time,created_by,critical,end_time,id,input,number,output,start_time,status,update_time",
+      "-predicate",
+      "{\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"createTime\": {\"endTime\": \"2018-05-25T23:50:17Z\"}, \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}"
+    ],
+    "infra_step": true,
+    "name": "fetch previous build",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@raw_io.output_text@{\"status\": \"FAILURE\", \"builder\": {\"project\": \"project\", \"builder\": \"builder\", \"bucket\": \"ci\"}, \"createTime\": \"2018-05-25T23:50:17Z\", \"createdBy\": \"user:luci-scheduler@appspot.gserviceaccount.com\", \"input\": {\"gitilesCommit\": {\"project\": \"project\", \"host\": \"chromium.googlesource.com\", \"ref\": \"refs/heads/master\", \"id\": \"2d72510e447ab60a9728aeea2362d8be2cbd7789\"}}, \"infra\": {\"swarming\": {\"priority\": 30}}, \"id\": \"8945511751514863184\"}@@@",
+      "@@@STEP_LOG_END@raw_io.output_text@@@",
+      "@@@STEP_LINK@8945511751514863184@https://cr-buildbucket.appspot.com/build/8945511751514863184@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-without-current-revision.json b/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-without-current-revision.json
new file mode 100644
index 0000000..b6042b6
--- /dev/null
+++ b/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-without-current-revision.json
@@ -0,0 +1,5 @@
+[
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-without-previous-build.json b/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-without-previous-build.json
new file mode 100644
index 0000000..7647f3f
--- /dev/null
+++ b/recipe_modules/bisect_build/tests/tests.expected/do-not-start-bisect-without-previous-build.json
@@ -0,0 +1,26 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "ls",
+      "-host",
+      "cr-buildbucket.appspot.com",
+      "-json",
+      "-nopage",
+      "-n",
+      "1",
+      "-fields",
+      "builder,create_time,created_by,critical,end_time,id,input,number,output,start_time,status,update_time",
+      "-predicate",
+      "{\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"createTime\": {\"endTime\": \"2018-05-25T23:50:17Z\"}, \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}"
+    ],
+    "infra_step": true,
+    "name": "fetch previous build",
+    "~followup_annotations": [
+      "@@@STEP_LOG_END@raw_io.output_text@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/dart/flutter_engine.expected/fan-out-on-distinct-failure.json b/recipe_modules/bisect_build/tests/tests.expected/fan-out-on-distinct-failure.json
similarity index 78%
rename from recipes/dart/flutter_engine.expected/fan-out-on-distinct-failure.json
rename to recipe_modules/bisect_build/tests/tests.expected/fan-out-on-distinct-failure.json
index bb97a19..42a9ee5 100644
--- a/recipes/dart/flutter_engine.expected/fan-out-on-distinct-failure.json
+++ b/recipe_modules/bisect_build/tests/tests.expected/fan-out-on-distinct-failure.json
@@ -8,7 +8,7 @@
     ],
     "infra_step": true,
     "name": "schedule bisect (b)",
-    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"flutter-engine-linux\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"b\", \"project\": \"linear_sdk_flutter_engine\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"a\"], \"bisect_older\": [\"c\"], \"bisect_reason\": \"different failure\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
+    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"b\", \"project\": \"sdk\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"a\"], \"bisect_older\": [\"c\"], \"bisect_reason\": \"failure\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@json.output@{@@@",
       "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
@@ -16,7 +16,7 @@
       "@@@STEP_LOG_LINE@json.output@      \"scheduleBuild\": {@@@",
       "@@@STEP_LOG_LINE@json.output@        \"builder\": {@@@",
       "@@@STEP_LOG_LINE@json.output@          \"bucket\": \"ci\", @@@",
-      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"flutter-engine-linux\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"builder\", @@@",
       "@@@STEP_LOG_LINE@json.output@          \"project\": \"project\"@@@",
       "@@@STEP_LOG_LINE@json.output@        }, @@@",
       "@@@STEP_LOG_LINE@json.output@        \"id\": \"8922054662172514000\"@@@",
@@ -31,7 +31,7 @@
       "@@@STEP_LOG_LINE@request@      \"scheduleBuild\": {@@@",
       "@@@STEP_LOG_LINE@request@        \"builder\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"bucket\": \"ci\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"builder\": \"flutter-engine-linux\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": \"builder\", @@@",
       "@@@STEP_LOG_LINE@request@          \"project\": \"project\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"experimental\": \"NO\", @@@",
@@ -39,7 +39,7 @@
       "@@@STEP_LOG_LINE@request@        \"gitilesCommit\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"host\": \"dart.googlesource.com\", @@@",
       "@@@STEP_LOG_LINE@request@          \"id\": \"b\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"project\": \"linear_sdk_flutter_engine\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"project\": \"sdk\", @@@",
       "@@@STEP_LOG_LINE@request@          \"ref\": \"refs/heads/master\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"priority\": 30, @@@",
@@ -50,7 +50,7 @@
       "@@@STEP_LOG_LINE@request@          \"bisect_older\": [@@@",
       "@@@STEP_LOG_LINE@request@            \"c\"@@@",
       "@@@STEP_LOG_LINE@request@          ], @@@",
-      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"different failure\"@@@",
+      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"failure\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", @@@",
       "@@@STEP_LOG_LINE@request@        \"tags\": [@@@",
@@ -76,7 +76,7 @@
     ],
     "infra_step": true,
     "name": "schedule bisect (d)",
-    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"flutter-engine-linux\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"d\", \"project\": \"linear_sdk_flutter_engine\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"c\"], \"bisect_older\": [\"e\"], \"bisect_reason\": \"Infra Failure: Step('everything') (retcode: 1)\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-00000000133a\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
+    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"d\", \"project\": \"sdk\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"c\"], \"bisect_older\": [\"e\"], \"bisect_reason\": \"different failure\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-00000000133a\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@json.output@{@@@",
       "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
@@ -84,7 +84,7 @@
       "@@@STEP_LOG_LINE@json.output@      \"scheduleBuild\": {@@@",
       "@@@STEP_LOG_LINE@json.output@        \"builder\": {@@@",
       "@@@STEP_LOG_LINE@json.output@          \"bucket\": \"ci\", @@@",
-      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"flutter-engine-linux\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"builder\", @@@",
       "@@@STEP_LOG_LINE@json.output@          \"project\": \"project\"@@@",
       "@@@STEP_LOG_LINE@json.output@        }, @@@",
       "@@@STEP_LOG_LINE@json.output@        \"id\": \"8922054662172514001\"@@@",
@@ -99,7 +99,7 @@
       "@@@STEP_LOG_LINE@request@      \"scheduleBuild\": {@@@",
       "@@@STEP_LOG_LINE@request@        \"builder\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"bucket\": \"ci\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"builder\": \"flutter-engine-linux\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": \"builder\", @@@",
       "@@@STEP_LOG_LINE@request@          \"project\": \"project\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"experimental\": \"NO\", @@@",
@@ -107,7 +107,7 @@
       "@@@STEP_LOG_LINE@request@        \"gitilesCommit\": {@@@",
       "@@@STEP_LOG_LINE@request@          \"host\": \"dart.googlesource.com\", @@@",
       "@@@STEP_LOG_LINE@request@          \"id\": \"d\", @@@",
-      "@@@STEP_LOG_LINE@request@          \"project\": \"linear_sdk_flutter_engine\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"project\": \"sdk\", @@@",
       "@@@STEP_LOG_LINE@request@          \"ref\": \"refs/heads/master\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"priority\": 30, @@@",
@@ -118,7 +118,7 @@
       "@@@STEP_LOG_LINE@request@          \"bisect_older\": [@@@",
       "@@@STEP_LOG_LINE@request@            \"e\"@@@",
       "@@@STEP_LOG_LINE@request@          ], @@@",
-      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"Infra Failure: Step('everything') (retcode: 1)\"@@@",
+      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"different failure\"@@@",
       "@@@STEP_LOG_LINE@request@        }, @@@",
       "@@@STEP_LOG_LINE@request@        \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-00000000133a\", @@@",
       "@@@STEP_LOG_LINE@request@        \"tags\": [@@@",
diff --git a/recipe_modules/bisect_build/tests/tests.expected/starts bisection.json b/recipe_modules/bisect_build/tests/tests.expected/starts bisection.json
new file mode 100644
index 0000000..c3ee8a1
--- /dev/null
+++ b/recipe_modules/bisect_build/tests/tests.expected/starts bisection.json
@@ -0,0 +1,202 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "ls",
+      "-host",
+      "cr-buildbucket.appspot.com",
+      "-json",
+      "-nopage",
+      "-n",
+      "1",
+      "-fields",
+      "builder,create_time,created_by,critical,end_time,id,input,number,output,start_time,status,update_time",
+      "-predicate",
+      "{\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"createTime\": {\"endTime\": \"2018-05-25T23:50:17Z\"}, \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}"
+    ],
+    "infra_step": true,
+    "name": "fetch previous build",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@raw_io.output_text@{\"status\": \"SUCCESS\", \"builder\": {\"project\": \"project\", \"builder\": \"builder\", \"bucket\": \"ci\"}, \"createTime\": \"2018-05-25T23:50:17Z\", \"createdBy\": \"user:luci-scheduler@appspot.gserviceaccount.com\", \"input\": {\"gitilesCommit\": {\"project\": \"project\", \"host\": \"chromium.googlesource.com\", \"ref\": \"refs/heads/master\", \"id\": \"2d72510e447ab60a9728aeea2362d8be2cbd7789\"}}, \"infra\": {\"swarming\": {\"priority\": 30}}, \"id\": \"8945511751514863184\"}@@@",
+      "@@@STEP_LOG_END@raw_io.output_text@@@",
+      "@@@STEP_LINK@8945511751514863184@https://cr-buildbucket.appspot.com/build/8945511751514863184@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gitiles]/resources/gerrit_client.py",
+      "--json-file",
+      "/path/to/tmp/json",
+      "--url",
+      "https://dummy/repo/url/+log/2d72510e447ab60a9728aeea2362d8be2cbd7789..ffffffff",
+      "--format",
+      "json",
+      "--log-limit",
+      "0"
+    ],
+    "name": "gitiles log: 2d72510e447ab60a9728aeea2362d8be2cbd7789..ffffffff",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@<br />3 commits fetched@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"log\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"author\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"fake_master@fake_0.email.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"fake_master\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"commit\": \"bda185dc04d062d391039867ae671ac9133e9801\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"committer\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"fake_master@fake_0.email.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"fake_master\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"message\": \"fake master msg 0\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"parents\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"faed5d85b02d45b884a6c84a69c198c120b36380\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      ], @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"tree\": \"a32438c6a72030d92b00f5eb682e7745338e6668\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"tree_diff\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@        {@@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_id\": \"1f944b71d6fcc086596ced8938eff992cbe4e326\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_path\": \"a.py\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@        }@@@",
+      "@@@STEP_LOG_LINE@json.output@      ]@@@",
+      "@@@STEP_LOG_LINE@json.output@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"author\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"fake_master@fake_1.email.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"fake_master\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"commit\": \"8331c527346abecfe0ad081df241512bb4b7df50\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"committer\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"fake_master@fake_1.email.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"fake_master\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"message\": \"fake master msg 1\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"parents\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"c50e65308b4d20210b14a612d2afb6acb3e830d9\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      ], @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"tree\": \"97cbb78571a53287bb1f64a5679f84c396df55ac\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"tree_diff\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@        {@@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_id\": \"5e881d213594ff0042014750f2ad397eaaeebee4\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_path\": \"b.py\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@        }@@@",
+      "@@@STEP_LOG_LINE@json.output@      ]@@@",
+      "@@@STEP_LOG_LINE@json.output@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"author\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"fake_master@fake_2.email.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"fake_master\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"commit\": \"f4d35da881f8fd329a4d3e01dd78b66a502d5c49\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"committer\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"fake_master@fake_2.email.com\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"fake_master\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"message\": \"fake master msg 2\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"parents\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"73c504dc712dae16fdd38ba50db2a1c5a669cd22\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      ], @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"tree\": \"5ae2afc02495c1adee6a3233581f73dac4f7a76a\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"tree_diff\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@        {@@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_id\": \"38952d2a55008e5afca8d49d8dd78c448d1a7c6c\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"new_path\": \"c.py\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@        }@@@",
+      "@@@STEP_LOG_LINE@json.output@      ]@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "bb",
+      "batch",
+      "-host",
+      "cr-buildbucket.appspot.com"
+    ],
+    "infra_step": true,
+    "name": "schedule bisect (f4d35da881f8fd329a4d3e01dd78b66a502d5c49)",
+    "stdin": "{\"requests\": [{\"scheduleBuild\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"builder\", \"project\": \"project\"}, \"experimental\": \"NO\", \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", \"gitilesCommit\": {\"host\": \"dart.googlesource.com\", \"id\": \"f4d35da881f8fd329a4d3e01dd78b66a502d5c49\", \"project\": \"sdk\", \"ref\": \"refs/heads/master\"}, \"priority\": 30, \"properties\": {\"bisect_newer\": [\"8331c527346abecfe0ad081df241512bb4b7df50\"], \"bisect_older\": [], \"bisect_reason\": \"failure\"}, \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"recipe\"}]}}]}",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"scheduleBuild\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@          \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"builder\": \"builder\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"project\": \"project\"@@@",
+      "@@@STEP_LOG_LINE@json.output@        }, @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"id\": \"8922054662172514000\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@request@{@@@",
+      "@@@STEP_LOG_LINE@request@  \"requests\": [@@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"scheduleBuild\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": \"builder\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"project\": \"project\"@@@",
+      "@@@STEP_LOG_LINE@request@        }, @@@",
+      "@@@STEP_LOG_LINE@request@        \"experimental\": \"NO\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builder,createTime,createdBy,critical,endTime,id,input,number,output,startTime,status,updateTime\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"gitilesCommit\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"host\": \"dart.googlesource.com\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"id\": \"f4d35da881f8fd329a4d3e01dd78b66a502d5c49\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"project\": \"sdk\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"ref\": \"refs/heads/master\"@@@",
+      "@@@STEP_LOG_LINE@request@        }, @@@",
+      "@@@STEP_LOG_LINE@request@        \"priority\": 30, @@@",
+      "@@@STEP_LOG_LINE@request@        \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"bisect_newer\": [@@@",
+      "@@@STEP_LOG_LINE@request@            \"8331c527346abecfe0ad081df241512bb4b7df50\"@@@",
+      "@@@STEP_LOG_LINE@request@          ], @@@",
+      "@@@STEP_LOG_LINE@request@          \"bisect_older\": [], @@@",
+      "@@@STEP_LOG_LINE@request@          \"bisect_reason\": \"failure\"@@@",
+      "@@@STEP_LOG_LINE@request@        }, @@@",
+      "@@@STEP_LOG_LINE@request@        \"requestId\": \"8945511751514863184-00000000-0000-0000-0000-000000001337\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@          {@@@",
+      "@@@STEP_LOG_LINE@request@            \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"value\": \"recipe\"@@@",
+      "@@@STEP_LOG_LINE@request@          }@@@",
+      "@@@STEP_LOG_LINE@request@        ]@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@",
+      "@@@STEP_LINK@8922054662172514000@https://cr-buildbucket.appspot.com/build/8922054662172514000@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/bisect_build/tests/tests.py b/recipe_modules/bisect_build/tests/tests.py
new file mode 100644
index 0000000..33a6b68
--- /dev/null
+++ b/recipe_modules/bisect_build/tests/tests.py
@@ -0,0 +1,105 @@
+# Copyright (c) 2020, the Dart project authors. All rights reserved. Use of this
+# source code is governed by a BSD-style license that can be found in the
+# LICENSE file.
+
+from recipe_engine.post_process import DropExpectation, DoesNotRunRE, Filter, MustRun
+from recipe_engine.recipe_api import Property
+
+DEPS = [
+    'depot_tools/gitiles',
+    'bisect_build',
+    'recipe_engine/properties',
+    'recipe_engine/step',
+    'recipe_engine/buildbucket',
+]
+
+PROPERTIES = {
+    'current_failure':
+        Property(
+            kind=str,
+            help="Reason for current failure or 'SUCCESS'",
+            default="SUCCESS"),
+}
+
+
+def RunSteps(api, current_failure):
+  api.m.bisect_build.schedule("https://dummy/repo/url", current_failure)
+
+
+def _test(api, name, failure=None):
+  data = api.test(
+      name,
+      api.buildbucket.ci_build(
+          builder='builder',
+          git_repo='https://dart.googlesource.com/sdk',
+          revision='f' * 8))
+  if failure:
+    data += api.m.properties(current_failure=failure)
+  return data
+
+
+def GenTests(api):
+  yield _test(api, 'basic')
+
+  yield (_test(api, 'starts bisection', failure='failure') + api.step_data(
+      'gitiles log: 2d72510e447ab60a9728aeea2362d8be2cbd7789..ffffffff',
+      api.gitiles.make_log_test_data('master')) +
+         api.buildbucket.simulated_search_results(
+             [
+                 api.buildbucket.ci_build_message(status='SUCCESS'),
+             ],
+             step_name='fetch previous build') + api.post_process(
+                 MustRun,
+                 'schedule bisect (f4d35da881f8fd329a4d3e01dd78b66a502d5c49)'))
+
+  yield (_test(
+      api, 'do-not-start-bisect-if-previous-build-failed', failure="failure") +
+         api.buildbucket.simulated_search_results(
+             [
+                 api.buildbucket.ci_build_message(status='FAILURE'),
+             ],
+             step_name='fetch previous build') + api.post_process(
+                 DoesNotRunRE, r'schedule bisect.*'))
+
+  yield (_test(
+      api, 'do-not-start-bisect-without-previous-build', failure="failure") +
+         api.buildbucket.simulated_search_results(
+             [], step_name='fetch previous build') + api.post_process(
+                 DoesNotRunRE, r'schedule bisect.*'))
+
+  yield api.test(
+      'do-not-start-bisect-without-current-revision',
+      api.buildbucket.ci_build(
+          builder='builder',
+          git_repo='https://dart.googlesource.com/sdk',
+          revision=None), api.m.properties(current_failure="failure"),
+      api.post_process(DoesNotRunRE, r'schedule bisect.*'))
+
+  yield (_test(api, 'continue-bisect-on-failure', failure="failure") +
+         api.properties(
+             bisect_newer=['a', 'b', 'c'],
+             bisect_older=['c', 'd', 'e'],
+             bisect_reason="failure") + api.post_process(
+                 MustRun, 'schedule bisect (d)') + api.post_process(
+                     Filter('schedule bisect (d)')))
+
+  yield (_test(api, 'continue-bisect-on-success') + api.properties(
+      bisect_newer=['a', 'b', 'c'],
+      bisect_older=['c', 'd', 'e'],
+      bisect_reason="failure") + api.post_process(
+          MustRun, 'schedule bisect (b)') + api.post_process(
+              Filter('schedule bisect (b)')))
+
+  yield (_test(api, 'fan-out-on-distinct-failure', failure="different failure")
+         + api.properties(
+             bisect_newer=['a', 'b', 'c'],
+             bisect_older=['c', 'd', 'e'],
+             bisect_reason='failure') + api.post_process(
+                 MustRun, 'schedule bisect (b)') + api.post_process(
+                     MustRun, 'schedule bisect (d)') + api.post_process(
+                         Filter().include_re(r'schedule bisect \(.*\)')))
+
+  yield (_test(api, 'stop-bisect') + api.properties(
+      bisect_newer=[], bisect_older=[], bisect_reason='failure') +
+         api.post_process(DoesNotRunRE, r'schedule bisect.*') +
+         api.post_process(DropExpectation))
diff --git a/recipe_modules/dart/api.py b/recipe_modules/dart/api.py
index f81915e..2afc635 100644
--- a/recipe_modules/dart/api.py
+++ b/recipe_modules/dart/api.py
@@ -273,6 +273,7 @@
             self.m.buildbucket.builder_name.endswith('-dev') or
             self.m.buildbucket.builder_name.endswith('-stable'))
 
+
   def _try_builder(self):
     """Boolean that reports whether this a try builder.
        Some steps are not run on the try builders."""
@@ -331,8 +332,6 @@
 
   def _get_latest_tested_commit(self):
     builder = self._get_builder_dir()
-    # Note: The pre-approval script relies on this step being named
-    # gsutil_find_latest_build inside the nested step download_previous_results.
     latest_result = self.m.gsutil.download(
         'dart-test-results',
         'builders/%s/latest' % builder,
@@ -350,7 +349,6 @@
           name='get revision for latest build',
           ok_ret='any') # TODO(athom): succeed only if file does not exist
       revision = revision_result.raw_io.output_texts.get('revision')
-
     return (latest, revision)
 
 
@@ -1123,7 +1121,6 @@
 
 
 class StepResults:
-
   def __init__(self, m):
     self.logs = ''
     self.results = ''
diff --git a/recipes/dart/flutter_engine.expected/do-not-start-bisect-if-previous-build-failed.json b/recipes/dart/flutter_engine.expected/do-not-start-bisect-if-previous-build-failed.json
deleted file mode 100644
index 9171738..0000000
--- a/recipes/dart/flutter_engine.expected/do-not-start-bisect-if-previous-build-failed.json
+++ /dev/null
@@ -1,56 +0,0 @@
-[
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "rmcontents",
-      "[CACHE]/builder"
-    ],
-    "cwd": "[CACHE]/builder",
-    "infra_step": true,
-    "name": "everything",
-    "~followup_annotations": [
-      "@@@STEP_EXCEPTION@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "bb",
-      "ls",
-      "-host",
-      "cr-buildbucket.appspot.com",
-      "-json",
-      "-nopage",
-      "-n",
-      "1",
-      "-fields",
-      "builder,create_time,created_by,critical,end_time,id,input,number,output,start_time,status,update_time",
-      "-predicate",
-      "{\"builder\": {\"bucket\": \"ci\", \"builder\": \"flutter-engine-linux\", \"project\": \"project\"}, \"createTime\": {\"endTime\": \"2018-05-25T23:50:17Z\"}, \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}"
-    ],
-    "infra_step": true,
-    "name": "fetch previous build",
-    "~followup_annotations": [
-      "@@@STEP_LOG_LINE@raw_io.output_text@{\"status\": \"FAILURE\", \"builder\": {\"project\": \"project\", \"builder\": \"builder\", \"bucket\": \"ci\"}, \"createTime\": \"2018-05-25T23:50:17Z\", \"createdBy\": \"user:luci-scheduler@appspot.gserviceaccount.com\", \"input\": {\"gitilesCommit\": {\"project\": \"project\", \"host\": \"chromium.googlesource.com\", \"ref\": \"refs/heads/master\", \"id\": \"2d72510e447ab60a9728aeea2362d8be2cbd7789\"}}, \"infra\": {\"swarming\": {\"priority\": 30}}, \"id\": \"8945511751514863184\"}@@@",
-      "@@@STEP_LOG_END@raw_io.output_text@@@",
-      "@@@STEP_LINK@8945511751514863184@https://cr-buildbucket.appspot.com/build/8945511751514863184@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "python",
-      "-u",
-      "[CACHE]/builder/src/third_party/dart/tools/task_kill.py"
-    ],
-    "name": "kill processes"
-  },
-  {
-    "failure": {
-      "humanReason": "Infra Failure: Step('everything') (retcode: 1)"
-    },
-    "name": "$result"
-  }
-]
\ No newline at end of file
diff --git a/recipes/dart/flutter_engine.expected/do-not-start-bisect-without-previous-build.json b/recipes/dart/flutter_engine.expected/do-not-start-bisect-without-previous-build.json
deleted file mode 100644
index f293d5c..0000000
--- a/recipes/dart/flutter_engine.expected/do-not-start-bisect-without-previous-build.json
+++ /dev/null
@@ -1,54 +0,0 @@
-[
-  {
-    "cmd": [
-      "vpython",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "rmcontents",
-      "[CACHE]/builder"
-    ],
-    "cwd": "[CACHE]/builder",
-    "infra_step": true,
-    "name": "everything",
-    "~followup_annotations": [
-      "@@@STEP_EXCEPTION@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "bb",
-      "ls",
-      "-host",
-      "cr-buildbucket.appspot.com",
-      "-json",
-      "-nopage",
-      "-n",
-      "1",
-      "-fields",
-      "builder,create_time,created_by,critical,end_time,id,input,number,output,start_time,status,update_time",
-      "-predicate",
-      "{\"builder\": {\"bucket\": \"ci\", \"builder\": \"flutter-engine-linux\", \"project\": \"project\"}, \"createTime\": {\"endTime\": \"2018-05-25T23:50:17Z\"}, \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}"
-    ],
-    "infra_step": true,
-    "name": "fetch previous build",
-    "~followup_annotations": [
-      "@@@STEP_LOG_END@raw_io.output_text@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "python",
-      "-u",
-      "[CACHE]/builder/src/third_party/dart/tools/task_kill.py"
-    ],
-    "name": "kill processes"
-  },
-  {
-    "failure": {
-      "humanReason": "Infra Failure: Step('everything') (retcode: 1)"
-    },
-    "name": "$result"
-  }
-]
\ No newline at end of file
diff --git a/recipes/dart/flutter_engine.py b/recipes/dart/flutter_engine.py
index 85516cd..68e12cf 100644
--- a/recipes/dart/flutter_engine.py
+++ b/recipes/dart/flutter_engine.py
@@ -8,11 +8,8 @@
 from recipe_engine.post_process import (
     DoesNotRunRE, DropExpectation, Filter, MustRun)
 
-from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
-from PB.go.chromium.org.luci.buildbucket.proto import rpc as rpc_pb2
-
-
 DEPS = [
+    'bisect_build',
     'dart',
     'depot_tools/bot_update',
     'depot_tools/depot_tools',
@@ -327,25 +324,15 @@
 
     BuildAndTest(api, start_dir, checkout_dir, flutter_rev)
   except recipe_api.StepFailure as failure:
-    if _is_bisecting(api):
-      bisect_reason = api.properties['bisect_reason']
-      if bisect_reason == failure.reason:
-        _bisect_older(api, bisect_reason)
-      else:
-        _bisect_newer(api, bisect_reason)
-        # The build failed for a different reason, fan out to find the root
-        # cause of that failure as well.
-        _bisect_older(api, failure.reason)
-    elif api.buildbucket.gitiles_commit.id:
-      # Tryjobs don't have an input commit and can't be bisected.
-      _start_bisection(api, failure.reason)
+    if api.buildbucket.gitiles_commit.id:
+      api.m.bisect_build.schedule(LINEARIZED_REPO_URL, failure.reason)
     raise
   finally:
     # TODO(aam): Go back to `ok_ret={0}` once dartbug.com/35549 is fixed
     KillTasks(api, checkout_dir, ok_ret='any')
-  if _is_bisecting(api):
+  if api.m.bisect_build.is_bisecting():
     # The build was successful, so search newer builds to find the root cause.
-    _bisect_newer(api, api.properties['bisect_reason'])
+    api.m.bisect_build.schedule(LINEARIZED_REPO_URL, "SUCCESS")
 
 
 def BuildAndTest(api, start_dir, checkout_dir, flutter_rev):
@@ -407,86 +394,6 @@
         TestFlutter(api, start_dir, just_built_dart_sdk)
 
 
-def _is_bisecting(api):
-  return 'bisect_reason' in api.properties
-
-
-def _start_bisection(api, reason):
-  current_rev = api.buildbucket.gitiles_commit.id
-
-  # Search for previous builds created by the Luci scheduler (to exclude
-  # bisection builds). The TimeRange includes all builds from start_time
-  # (defaults to 0) to end_time (exclusive). Because builds are ordered by
-  # create_time, the first result will be the previous build.
-  create_time = common_pb2.TimeRange(end_time=api.buildbucket.build.create_time)
-  builder = api.buildbucket.build.builder
-  search_predicate = rpc_pb2.BuildPredicate(
-      builder=builder,
-      create_time=create_time,
-      tags=[common_pb2.StringPair(key='user_agent', value='luci-scheduler')])
-  result = api.buildbucket.search(search_predicate, limit=1,
-      step_name='fetch previous build')
-  if not result or len(result) == 0:
-    # There is no previous build: do not bisect on new builders.
-    return
-  previous_build = result[0]
-  if previous_build.status == common_pb2.FAILURE:
-    # Do not bisect if the previous build failed.
-    # TODO(athom): Check if the failure reason is the same when
-    #              api.buildbucket.search supports adding
-    #              'builds.*.summaryMarkdown' to the field mask.
-    return
-  previous_rev = previous_build.input.gitiles_commit.id
-  # We're intentionally not paging through the log to avoid bisecting an
-  # excessive number of commits.
-  commits, _ = api.gitiles.log(
-      url=LINEARIZED_REPO_URL,
-      ref='%s..%s' % (previous_rev, current_rev)
-  )
-  commits = commits[1:] # The first commit is the current_rev
-  commits = [commit['commit'] for commit in commits]
-
-  _bisect(api, commits, reason)
-
-
-def _bisect(api, commits, reason):
-  if len(commits) == 0:
-    # Nothing more to bisect.
-    return
-
-  middle_index = len(commits)//2
-  newer = commits[:middle_index]
-  middle = commits[middle_index]
-  older = commits[middle_index + 1:]
-
-  commit = api.buildbucket.gitiles_commit
-  middle_commit = common_pb2.GitilesCommit()
-  middle_commit.CopyFrom(commit)
-  middle_commit.id = middle
-  builder = api.buildbucket.build.builder
-  request = api.buildbucket.schedule_request(
-    builder=builder.builder,
-    project=builder.project,
-    bucket=builder.bucket,
-    properties={
-      'bisect_newer': newer,
-      'bisect_older': older,
-      'bisect_reason': reason
-    },
-    gitiles_commit = middle_commit,
-    inherit_buildsets = False,
-  )
-  api.buildbucket.schedule([request], step_name='schedule bisect (%s)' % middle)
-
-
-def _bisect_newer(api, reason):
-  _bisect(api, list(api.properties.get('bisect_newer', [])), reason)
-
-
-def _bisect_older(api, reason):
-  _bisect(api, list(api.properties.get('bisect_older', [])), reason)
-
-
 def _test(api, name, failure=False):
   data = api.test(
       name,
@@ -503,6 +410,7 @@
     data += api.step_data('everything', retcode=1)
   return data
 
+
 def GenTests(api):
   yield (_test(api, 'flutter-engine-linux')
       + api.post_process(DoesNotRunRE, r'schedule bisect.*'))
@@ -517,43 +425,9 @@
       + api.post_process(MustRun,
           'schedule bisect (f4d35da881f8fd329a4d3e01dd78b66a502d5c49)'))
 
-  yield (_test(api, 'do-not-start-bisect-if-previous-build-failed',
-          failure=True)
-      + api.buildbucket.simulated_search_results([
-             api.buildbucket.ci_build_message(status='FAILURE'),
-          ], step_name='fetch previous build')
-      + api.post_process(DoesNotRunRE, r'schedule bisect.*'))
-
-  yield (_test(api, 'do-not-start-bisect-without-previous-build', failure=True)
-      + api.buildbucket.simulated_search_results([],
-          step_name='fetch previous build')
-      + api.post_process(DoesNotRunRE, r'schedule bisect.*'))
-
-  yield (_test(api, 'continue-bisect-on-failure', failure=True)
-      + api.properties(
-          bisect_newer=['a', 'b', 'c'], bisect_older=['c', 'd', 'e'],
-          bisect_reason="Infra Failure: Step('everything') (retcode: 1)")
-      + api.post_process(MustRun, 'schedule bisect (d)')
-      + api.post_process(Filter('schedule bisect (d)')))
-
-  yield (_test(api, 'continue-bisect-on-success')
-      + api.properties(
-          bisect_newer=['a', 'b', 'c'], bisect_older=['c', 'd', 'e'],
-          bisect_reason="Infra Failure: Step('everything') (retcode: 1)")
-      + api.post_process(MustRun, 'schedule bisect (b)')
-      + api.post_process(Filter('schedule bisect (b)')))
-
-  yield (_test(api, 'fan-out-on-distinct-failure', failure=True)
-      + api.properties(
-          bisect_newer=['a', 'b', 'c'], bisect_older=['c', 'd', 'e'],
-          bisect_reason='different failure')
-      + api.post_process(MustRun, 'schedule bisect (b)')
-      + api.post_process(MustRun, 'schedule bisect (d)')
-      + api.post_process(Filter().include_re(r'schedule bisect \(.*\)')))
-
-  yield (_test(api, 'stop-bisect')
-      + api.properties(
-          bisect_newer=[], bisect_older=[],
-          bisect_reason='different failure')
-      + api.post_process(DoesNotRunRE, r'schedule bisect.*')
-      + api.post_process(DropExpectation))
+  yield (_test(api, 'continue-bisect-on-success') + api.properties(
+      bisect_newer=['a', 'b', 'c'],
+      bisect_older=['c', 'd', 'e'],
+      bisect_reason="Infra Failure: Step('everything') (retcode: 1)") +
+         api.post_process(MustRun, 'schedule bisect (b)') + api.post_process(
+             Filter('schedule bisect (b)')))