[recipes-py] Provide the ability to supply reason when cancel a build

  * Switches the underlying command from old `buildbucket` tool to `bb`,
    which requires a mandatory cancellation reason
  * Clean up the method signature to stop taking kwargs

R=iannucci, iannucci@google.com

Bug: 1004545
Change-Id: Iab31fc10ca919309e0b838bed7dd27751014d86e
Recipe-Nontrivial-Roll: build
Recipe-Manual-Change: fuchsia
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/recipes-py/+/1918014
Commit-Queue: Yiwei Zhang <yiwzhang@google.com>
Reviewed-by: Robbie Iannucci <iannucci@chromium.org>
Auto-Submit: Yiwei Zhang <yiwzhang@google.com>
diff --git a/README.recipes.md b/README.recipes.md
index 923a952..ad386ae 100644
--- a/README.recipes.md
+++ b/README.recipes.md
@@ -43,6 +43,7 @@
   * [buildbucket:examples/full](#recipes-buildbucket_examples_full) &mdash; This file is a recipe demonstrating the buildbucket recipe module.
   * [buildbucket:run/multi](#recipes-buildbucket_run_multi) &mdash; Launches multiple builds at the same revision.
   * [buildbucket:tests/build](#recipes-buildbucket_tests_build)
+  * [buildbucket:tests/cancel](#recipes-buildbucket_tests_cancel)
   * [buildbucket:tests/collect](#recipes-buildbucket_tests_collect)
   * [buildbucket:tests/get](#recipes-buildbucket_tests_get)
   * [buildbucket:tests/put](#recipes-buildbucket_tests_put)
@@ -257,17 +258,17 @@
 `build_pb2.Build` and returns a link title.
 If it returns `None`, the link is not reported. Default link title is build id.
 
-#### **class [BuildbucketApi](/recipe_modules/buildbucket/api.py#29)([RecipeApi](/recipe_engine/recipe_api.py#871)):**
+#### **class [BuildbucketApi](/recipe_modules/buildbucket/api.py#27)([RecipeApi](/recipe_engine/recipe_api.py#871)):**
 
 A module for interacting with buildbucket.
 
-&emsp; **@property**<br>&mdash; **def [bucket\_v1](/recipe_modules/buildbucket/api.py#887)(self):**
+&emsp; **@property**<br>&mdash; **def [bucket\_v1](/recipe_modules/buildbucket/api.py#928)(self):**
 
 Returns bucket name in v1 format.
 
 Mostly useful for scheduling new builds using V1 API.
 
-&emsp; **@property**<br>&mdash; **def [build](/recipe_modules/buildbucket/api.py#117)(self):**
+&emsp; **@property**<br>&mdash; **def [build](/recipe_modules/buildbucket/api.py#115)(self):**
 
 Returns current build as a `buildbucket.v2.Build` protobuf message.
 
@@ -284,19 +285,19 @@
 the rules described in the .proto files.
 If the current build is not a buildbucket build, returned `build.id` is 0.
 
-&emsp; **@property**<br>&mdash; **def [build\_id](/recipe_modules/buildbucket/api.py#898)(self):**
+&emsp; **@property**<br>&mdash; **def [build\_id](/recipe_modules/buildbucket/api.py#939)(self):**
 
 DEPRECATED, use build.id instead.
 
-&emsp; **@property**<br>&mdash; **def [build\_input](/recipe_modules/buildbucket/api.py#903)(self):**
+&emsp; **@property**<br>&mdash; **def [build\_input](/recipe_modules/buildbucket/api.py#944)(self):**
 
 DEPRECATED, use build.input instead.
 
-&mdash; **def [build\_url](/recipe_modules/buildbucket/api.py#141)(self, host=None, build_id=None):**
+&mdash; **def [build\_url](/recipe_modules/buildbucket/api.py#139)(self, host=None, build_id=None):**
 
 Returns url to a build. Defaults to current build.
 
-&emsp; **@property**<br>&mdash; **def [builder\_cache\_path](/recipe_modules/buildbucket/api.py#247)(self):**
+&emsp; **@property**<br>&mdash; **def [builder\_cache\_path](/recipe_modules/buildbucket/api.py#245)(self):**
 
 Path to the builder cache directory.
 
@@ -305,17 +306,30 @@
 See "Builder cache" in
 https://chromium.googlesource.com/infra/luci/luci-go/+/master/buildbucket/proto/project_config.proto
 
-&emsp; **@property**<br>&mdash; **def [builder\_id](/recipe_modules/buildbucket/api.py#908)(self):**
+&emsp; **@property**<br>&mdash; **def [builder\_id](/recipe_modules/buildbucket/api.py#949)(self):**
 
 Deprecated. Use build.builder instead.
 
-&emsp; **@property**<br>&mdash; **def [builder\_name](/recipe_modules/buildbucket/api.py#136)(self):**
+&emsp; **@property**<br>&mdash; **def [builder\_name](/recipe_modules/buildbucket/api.py#134)(self):**
 
 Returns builder name. Shortcut for `.build.builder.builder`.
 
-&mdash; **def [cancel\_build](/recipe_modules/buildbucket/api.py#637)(self, build_id, \*\*kwargs):**
+&mdash; **def [cancel\_build](/recipe_modules/buildbucket/api.py#635)(self, build_id, reason=' ', step_name=None):**
 
-&mdash; **def [collect\_build](/recipe_modules/buildbucket/api.py#704)(self, build_id, \*\*kwargs):**
+Cancel the build associated with the provided build id.
+
+Args:
+*   `build_id` (int|str): a buildbucket build ID.
+               It should be either an integer(e.g. 123456789 or '123456789')
+               or the numeric value in string format.
+*   `reason` (str): reason for canceling the given build.
+              Can't be None or Empty. Markdown is supported.
+
+Returns:
+  None if build is successfully cancelled. Otherwise, an InfraFailure will
+  be raised
+
+&mdash; **def [collect\_build](/recipe_modules/buildbucket/api.py#736)(self, build_id, \*\*kwargs):**
 
 Shorthand for `collect_builds` below, but for a single build only.
 
@@ -326,7 +340,7 @@
   [Build](https://chromium.googlesource.com/infra/luci/luci-go/+/master/buildbucket/proto/build.proto).
   for the ended build.
 
-&mdash; **def [collect\_builds](/recipe_modules/buildbucket/api.py#717)(self, build_ids, interval=None, timeout=None, step_name=None, raise_if_unsuccessful=False, url_title_fn=None, mirror_status=False, fields=DEFAULT_FIELDS):**
+&mdash; **def [collect\_builds](/recipe_modules/buildbucket/api.py#749)(self, build_ids, interval=None, timeout=None, step_name=None, raise_if_unsuccessful=False, url_title_fn=None, mirror_status=False, fields=DEFAULT_FIELDS):**
 
 Waits for a set of builds to end and returns their details.
 
@@ -349,7 +363,7 @@
   [Build](https://chromium.googlesource.com/infra/luci/luci-go/+/master/buildbucket/proto/build.proto)
   for all specified builds.
 
-&mdash; **def [get](/recipe_modules/buildbucket/api.py#683)(self, build_id, url_title_fn=None, step_name=None):**
+&mdash; **def [get](/recipe_modules/buildbucket/api.py#715)(self, build_id, url_title_fn=None, step_name=None):**
 
 Gets a build.
 
@@ -361,11 +375,11 @@
 Returns:
   A build_pb2.Build.
 
-&mdash; **def [get\_build](/recipe_modules/buildbucket/api.py#700)(self, build_id, \*\*kwargs):**
+&mdash; **def [get\_build](/recipe_modules/buildbucket/api.py#732)(self, build_id, \*\*kwargs):**
 
 DEPRECATED. Use get().
 
-&mdash; **def [get\_multi](/recipe_modules/buildbucket/api.py#640)(self, build_ids, url_title_fn=None, step_name=None, fields=DEFAULT_FIELDS):**
+&mdash; **def [get\_multi](/recipe_modules/buildbucket/api.py#672)(self, build_ids, url_title_fn=None, step_name=None, fields=DEFAULT_FIELDS):**
 
 Gets multiple builds.
 
@@ -379,7 +393,7 @@
 Returns:
   A dict {build_id: build_pb2.Build}.
 
-&emsp; **@property**<br>&mdash; **def [gitiles\_commit](/recipe_modules/buildbucket/api.py#146)(self):**
+&emsp; **@property**<br>&mdash; **def [gitiles\_commit](/recipe_modules/buildbucket/api.py#144)(self):**
 
 Returns input gitiles commit. Shortcut for `.build.input.gitiles_commit`.
 
@@ -388,14 +402,14 @@
 
 Never returns None, but sub-fields may be empty.
 
-&emsp; **@host.setter**<br>&mdash; **def [host](/recipe_modules/buildbucket/api.py#95)(self, value):**
+&emsp; **@host.setter**<br>&mdash; **def [host](/recipe_modules/buildbucket/api.py#93)(self, value):**
 
-&mdash; **def [is\_critical](/recipe_modules/buildbucket/api.py#157)(self, build=None):**
+&mdash; **def [is\_critical](/recipe_modules/buildbucket/api.py#155)(self, build=None):**
 
 Returns True if the build is critical. Build defaults to the current one.
     
 
-&mdash; **def [put](/recipe_modules/buildbucket/api.py#537)(self, builds, \*\*kwargs):**
+&mdash; **def [put](/recipe_modules/buildbucket/api.py#535)(self, builds, \*\*kwargs):**
 
 Puts a batch of builds.
 
@@ -419,7 +433,7 @@
   A step that as its `.stdout` property contains the response object as
   returned by buildbucket.
 
-&mdash; **def [run](/recipe_modules/buildbucket/api.py#268)(self, schedule_build_requests, collect_interval=None, timeout=None, url_title_fn=None, step_name=None, raise_if_unsuccessful=False):**
+&mdash; **def [run](/recipe_modules/buildbucket/api.py#266)(self, schedule_build_requests, collect_interval=None, timeout=None, url_title_fn=None, step_name=None, raise_if_unsuccessful=False):**
 
 Runs builds and returns results.
 
@@ -431,7 +445,7 @@
   [Builds](https://chromium.googlesource.com/infra/luci/luci-go/+/master/buildbucket/proto/build.proto)
   in the same order as schedule_build_requests.
 
-&mdash; **def [schedule](/recipe_modules/buildbucket/api.py#461)(self, schedule_build_requests, url_title_fn=None, step_name=None):**
+&mdash; **def [schedule](/recipe_modules/buildbucket/api.py#459)(self, schedule_build_requests, url_title_fn=None, step_name=None):**
 
 Schedules a batch of builds.
 
@@ -460,7 +474,7 @@
 Raises:
   `InfraFailure` if any of the requests fail.
 
-&mdash; **def [schedule\_request](/recipe_modules/buildbucket/api.py#296)(self, builder, project=INHERIT, bucket=INHERIT, properties=None, experimental=INHERIT, gitiles_commit=INHERIT, gerrit_changes=INHERIT, tags=None, inherit_buildsets=True, swarming_parent_run_id=None, dimensions=None, priority=INHERIT, critical=INHERIT, exe_cipd_version=INHERIT, fields=DEFAULT_FIELDS):**
+&mdash; **def [schedule\_request](/recipe_modules/buildbucket/api.py#294)(self, builder, project=INHERIT, bucket=INHERIT, properties=None, experimental=INHERIT, gitiles_commit=INHERIT, gerrit_changes=INHERIT, tags=None, inherit_buildsets=True, swarming_parent_run_id=None, dimensions=None, priority=INHERIT, critical=INHERIT, exe_cipd_version=INHERIT, fields=DEFAULT_FIELDS):**
 
 Creates a new `ScheduleBuildRequest` message with reasonable defaults.
 
@@ -525,7 +539,7 @@
 * fields (list of strs): a list of fields to include in the response, names
   relative to `build_pb2.Build` (e.g. ["tags", "infra.swarming"]).
 
-&mdash; **def [search](/recipe_modules/buildbucket/api.py#572)(self, predicate, limit=None, url_title_fn=None, step_name=None, fields=DEFAULT_FIELDS):**
+&mdash; **def [search](/recipe_modules/buildbucket/api.py#570)(self, predicate, limit=None, url_title_fn=None, step_name=None, fields=DEFAULT_FIELDS):**
 
 Searches for builds.
 
@@ -550,11 +564,11 @@
 Returns:
   A list of builds ordered newest-to-oldest.
 
-&mdash; **def [set\_buildbucket\_host](/recipe_modules/buildbucket/api.py#99)(self, host):**
+&mdash; **def [set\_buildbucket\_host](/recipe_modules/buildbucket/api.py#97)(self, host):**
 
 DEPRECATED. Use host property.
 
-&mdash; **def [set\_output\_gitiles\_commit](/recipe_modules/buildbucket/api.py#200)(self, gitiles_commit):**
+&mdash; **def [set\_output\_gitiles\_commit](/recipe_modules/buildbucket/api.py#198)(self, gitiles_commit):**
 
 Sets `buildbucket.v2.Build.output.gitiles_commit` field.
 
@@ -572,16 +586,16 @@
 
 Can be called at most once per build.
 
-&mdash; **def [tags](/recipe_modules/buildbucket/api.py#243)(self, \*\*tags):**
+&mdash; **def [tags](/recipe_modules/buildbucket/api.py#241)(self, \*\*tags):**
 
 Alias for tags in util.py. See doc there.
 
-&emsp; **@property**<br>&mdash; **def [tags\_for\_child\_build](/recipe_modules/buildbucket/api.py#163)(self):**
+&emsp; **@property**<br>&mdash; **def [tags\_for\_child\_build](/recipe_modules/buildbucket/api.py#161)(self):**
 
 A dict of tags (key -> value) derived from current (parent) build for a
 child build.
 
-&mdash; **def [use\_service\_account\_key](/recipe_modules/buildbucket/api.py#103)(self, key_path):**
+&mdash; **def [use\_service\_account\_key](/recipe_modules/buildbucket/api.py#101)(self, key_path):**
 
 Tells this module to start using given service account key for auth.
 
@@ -2827,6 +2841,11 @@
 [DEPS](/recipe_modules/buildbucket/tests/build.py#16): [assertions](#recipe_modules-assertions), [buildbucket](#recipe_modules-buildbucket), [properties](#recipe_modules-properties), [step](#recipe_modules-step)
 
 &mdash; **def [RunSteps](/recipe_modules/buildbucket/tests/build.py#24)(api):**
+### *recipes* / [buildbucket:tests/cancel](/recipe_modules/buildbucket/tests/cancel.py)
+
+[DEPS](/recipe_modules/buildbucket/tests/cancel.py#9): [buildbucket](#recipe_modules-buildbucket)
+
+&mdash; **def [RunSteps](/recipe_modules/buildbucket/tests/cancel.py#13)(api):**
 ### *recipes* / [buildbucket:tests/collect](/recipe_modules/buildbucket/tests/collect.py)
 
 [DEPS](/recipe_modules/buildbucket/tests/collect.py#5): [buildbucket](#recipe_modules-buildbucket), [properties](#recipe_modules-properties)
diff --git a/recipe_modules/buildbucket/api.py b/recipe_modules/buildbucket/api.py
index 4a75e19..1f00c6d 100644
--- a/recipe_modules/buildbucket/api.py
+++ b/recipe_modules/buildbucket/api.py
@@ -12,8 +12,6 @@
 If it returns `None`, the link is not reported. Default link title is build id.
 """
 
-import json
-
 from google import protobuf
 from google.protobuf import field_mask_pb2
 from google.protobuf import json_format
@@ -634,8 +632,42 @@
       self._report_build_maybe(step_res, b, url_title_fn=url_title_fn)
     return ret
 
-  def cancel_build(self, build_id, **kwargs):
-    return self._run_buildbucket('cancel', [build_id], **kwargs)
+  def cancel_build(self, build_id, reason=' ', step_name=None):
+    """Cancel the build associated with the provided build id.
+
+    Args:
+    *   `build_id` (int|str): a buildbucket build ID.
+                   It should be either an integer(e.g. 123456789 or '123456789')
+                   or the numeric value in string format.
+    *   `reason` (str): reason for canceling the given build.
+                  Can't be None or Empty. Markdown is supported.
+
+    Returns:
+      None if build is successfully cancelled. Otherwise, an InfraFailure will
+      be raised
+    """
+    self._check_build_id(build_id)
+    cancel_req = rpc_pb2.BatchRequest(
+      requests=[
+        dict(cancel_build=dict(
+          # Expecting id to be of type int64 according to the proto definition
+          id=int(build_id),
+          summary_markdown=str(reason)
+        ))])
+    test_res = rpc_pb2.BatchResponse(
+      responses=[
+        dict(cancel_build=dict(
+          id=int(build_id),
+          status=common_pb2.CANCELED
+        ))])
+    _, batch_res, has_errors = self._batch_request(
+      step_name or 'buildbucket.cancel', cancel_req, test_res)
+
+    if has_errors:
+      raise self.m.step.InfraFailure('Failed to cancel build[%s]. Message: %s' %(
+        build_id, batch_res.responses[0].error.message))
+
+    return None
 
   def get_multi(self, build_ids, url_title_fn=None, step_name=None,
                 fields=DEFAULT_FIELDS):
@@ -808,7 +840,7 @@
     step_res = self.m.step.active_result
 
     # Log the request.
-    step_res.presentation.logs['request'] = json.dumps(
+    step_res.presentation.logs['request'] = self.m.json.dumps(
         request_dict, indent=2, sort_keys=True).splitlines()
 
     # Parse the response.
@@ -884,6 +916,15 @@
         for k, v in new_tags.iteritems()
         if v is not None)
 
+  def _check_build_id(self, build_id):
+    """Raise ValueError if the given build id is not a number or a string
+    that represents numeric value.
+    """
+    is_int = isinstance(build_id, (int, long))
+    is_str_num = isinstance(build_id, str) and build_id.isdigit()
+    if not (is_int or is_str_num):
+      raise ValueError('Expected a numeric build id, got %s' %(build_id,))
+
   @property
   def bucket_v1(self):
     """Returns bucket name in v1 format.
diff --git a/recipe_modules/buildbucket/examples/full.expected/basic-ci-win.json b/recipe_modules/buildbucket/examples/full.expected/basic-ci-win.json
index 0ff1f64..c9c53dd 100644
--- a/recipe_modules/buildbucket/examples/full.expected/basic-ci-win.json
+++ b/recipe_modules/buildbucket/examples/full.expected/basic-ci-win.json
@@ -80,20 +80,37 @@
   },
   {
     "cmd": [
-      "buildbucket",
-      "cancel",
+      "bb",
+      "batch",
       "-host",
-      "cr-buildbucket.appspot.com",
-      "-service-account-json",
-      "some-fake-key.json",
-      "9016911228971028736"
+      "cr-buildbucket.appspot.com"
     ],
     "infra_step": true,
     "name": "buildbucket.cancel",
+    "stdin": "{\"requests\": [{\"cancelBuild\": {\"id\": \"9016911228971028736\", \"summaryMarkdown\": \" \"}}]}",
     "~followup_annotations": [
-      "@@@STEP_LOG_END@json.output (invalid)@@@",
-      "@@@STEP_LOG_LINE@json.output (exception)@No JSON object could be decoded@@@",
-      "@@@STEP_LOG_END@json.output (exception)@@@"
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"id\": \"9016911228971028736\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"status\": \"CANCELED\"@@@",
+      "@@@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@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"id\": \"9016911228971028736\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"summaryMarkdown\": \" \"@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
     ]
   },
   {
diff --git a/recipe_modules/buildbucket/examples/full.expected/basic-try.json b/recipe_modules/buildbucket/examples/full.expected/basic-try.json
index a610034..7646b07 100644
--- a/recipe_modules/buildbucket/examples/full.expected/basic-try.json
+++ b/recipe_modules/buildbucket/examples/full.expected/basic-try.json
@@ -80,20 +80,37 @@
   },
   {
     "cmd": [
-      "buildbucket",
-      "cancel",
+      "bb",
+      "batch",
       "-host",
-      "cr-buildbucket.appspot.com",
-      "-service-account-json",
-      "some-fake-key.json",
-      "9016911228971028736"
+      "cr-buildbucket.appspot.com"
     ],
     "infra_step": true,
     "name": "buildbucket.cancel",
+    "stdin": "{\"requests\": [{\"cancelBuild\": {\"id\": \"9016911228971028736\", \"summaryMarkdown\": \" \"}}]}",
     "~followup_annotations": [
-      "@@@STEP_LOG_END@json.output (invalid)@@@",
-      "@@@STEP_LOG_LINE@json.output (exception)@No JSON object could be decoded@@@",
-      "@@@STEP_LOG_END@json.output (exception)@@@"
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"id\": \"9016911228971028736\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"status\": \"CANCELED\"@@@",
+      "@@@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@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"id\": \"9016911228971028736\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"summaryMarkdown\": \" \"@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
     ]
   },
   {
diff --git a/recipe_modules/buildbucket/test_api.py b/recipe_modules/buildbucket/test_api.py
index b972b60..45a55c7 100644
--- a/recipe_modules/buildbucket/test_api.py
+++ b/recipe_modules/buildbucket/test_api.py
@@ -233,12 +233,7 @@
 
   def simulated_schedule_output(self, batch_response, step_name=None):
     """Simulates a buildbucket.schedule call."""
-    assert isinstance(batch_response, rpc_pb2.BatchResponse)
-    step_name = step_name or 'buildbucket.schedule'
-    ret_code = int(any(r.HasField('error') for r in batch_response.responses))
-    jsonish = json_format.MessageToDict(batch_response)
-    return self.step_data(
-        step_name, self.m.json.output_stream(jsonish, retcode=ret_code))
+    return self._simulated_batch_response(batch_response, step_name or 'buildbucket.schedule')
 
   def simulated_search_results(self, builds, step_name=None):
     """Simulates a buildbucket.search call."""
@@ -266,3 +261,17 @@
         responses=[dict(get_build=b) for b in builds],
     ))
     return self.step_data(step_name, self.m.json.output_stream(jsonish))
+
+  def simulated_cancel_output(self, batch_response, step_name=None):
+    """Simulates a buildbucket.cancel call"""
+    return self._simulated_batch_response(batch_response, step_name or 'buildbucket.cancel')
+
+  def _simulated_batch_response(self, batch_response, step_name):
+    """Simulate that the given step will write the provided batch response into step data
+    The return code will be 1 for step data if the responses contain error. Otherwise, 0
+    """
+    assert isinstance(batch_response, rpc_pb2.BatchResponse)
+    ret_code = int(any(r.HasField('error') for r in batch_response.responses))
+    jsonish = json_format.MessageToDict(batch_response)
+    return self.step_data(
+        step_name, self.m.json.output_stream(jsonish, retcode=ret_code))
diff --git a/recipe_modules/buildbucket/tests/cancel.expected/basic.json b/recipe_modules/buildbucket/tests/cancel.expected/basic.json
new file mode 100644
index 0000000..dd14fe2
--- /dev/null
+++ b/recipe_modules/buildbucket/tests/cancel.expected/basic.json
@@ -0,0 +1,75 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "batch",
+      "-host",
+      "cr-buildbucket.appspot.com"
+    ],
+    "infra_step": true,
+    "name": "cancel_without_reason",
+    "stdin": "{\"requests\": [{\"cancelBuild\": {\"id\": \"1785294945718829\", \"summaryMarkdown\": \" \"}}]}",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"id\": \"1785294945718829\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"status\": \"CANCELED\"@@@",
+      "@@@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@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"id\": \"1785294945718829\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"summaryMarkdown\": \" \"@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "bb",
+      "batch",
+      "-host",
+      "cr-buildbucket.appspot.com"
+    ],
+    "infra_step": true,
+    "name": "cancel_with_reason",
+    "stdin": "{\"requests\": [{\"cancelBuild\": {\"id\": \"6838835292664158\", \"summaryMarkdown\": \"Discarded!!\"}}]}",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"id\": \"6838835292664158\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"status\": \"CANCELED\"@@@",
+      "@@@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@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"id\": \"6838835292664158\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"summaryMarkdown\": \"Discarded!!\"@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/buildbucket/tests/cancel.expected/error.json b/recipe_modules/buildbucket/tests/cancel.expected/error.json
new file mode 100644
index 0000000..64d5a44
--- /dev/null
+++ b/recipe_modules/buildbucket/tests/cancel.expected/error.json
@@ -0,0 +1,79 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "batch",
+      "-host",
+      "cr-buildbucket.appspot.com"
+    ],
+    "infra_step": true,
+    "name": "cancel_without_reason",
+    "stdin": "{\"requests\": [{\"cancelBuild\": {\"id\": \"1785294945718829\", \"summaryMarkdown\": \" \"}}]}",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"id\": \"1785294945718829\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"status\": \"CANCELED\"@@@",
+      "@@@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@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"id\": \"1785294945718829\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"summaryMarkdown\": \" \"@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "bb",
+      "batch",
+      "-host",
+      "cr-buildbucket.appspot.com"
+    ],
+    "infra_step": true,
+    "name": "cancel_with_reason",
+    "stdin": "{\"requests\": [{\"cancelBuild\": {\"id\": \"6838835292664158\", \"summaryMarkdown\": \"Discarded!!\"}}]}",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@Request #0<br>Status code: 123<br>Message: some error message<br>@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"error\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"code\": 123, @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"message\": \"some error message\"@@@",
+      "@@@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@      \"cancelBuild\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"id\": \"6838835292664158\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"summaryMarkdown\": \"Discarded!!\"@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
+    ]
+  },
+  {
+    "failure": {
+      "humanReason": "Failed to cancel build[6838835292664158]. Message: some error message"
+    },
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/buildbucket/tests/cancel.py b/recipe_modules/buildbucket/tests/cancel.py
new file mode 100644
index 0000000..17212d3
--- /dev/null
+++ b/recipe_modules/buildbucket/tests/cancel.py
@@ -0,0 +1,61 @@
+# Copyright 2019 The LUCI Authors. All rights reserved.
+# Use of this source code is governed under the Apache License, Version 2.0
+# that can be found in the LICENSE file.
+
+from PB.go.chromium.org.luci.buildbucket.proto import build as build_pb2
+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 = [
+  'buildbucket'
+]
+
+def RunSteps(api):
+  api.buildbucket.cancel_build(
+    1785294945718829, step_name='cancel_without_reason')
+  api.buildbucket.cancel_build(
+    6838835292664158, reason="Discarded!!", step_name='cancel_with_reason')
+  try:
+    api.buildbucket.cancel_build(
+      'invalid.build.id.12345', step_name='invalid_build_id')
+  except Exception as e:
+    assert isinstance(e, ValueError)
+
+def GenTests(api):
+  def construct_batch_response(build_id, status):
+    return rpc_pb2.BatchResponse(
+      responses=[
+        dict(cancel_build=dict(
+          id=build_id,
+          status=status
+        ))
+      ]
+    )
+
+  yield (
+      api.test('basic') +
+      api.buildbucket.simulated_cancel_output(
+        construct_batch_response(build_id=1785294945718829, status=common_pb2.CANCELED),
+        step_name='cancel_without_reason') +
+      api.buildbucket.simulated_cancel_output(
+        construct_batch_response(build_id=6838835292664158, status=common_pb2.CANCELED),
+        step_name='cancel_with_reason')
+  )
+
+  error_batch_response = rpc_pb2.BatchResponse(
+    responses=[
+      dict(error=dict(
+        code=123,
+        message='some error message'
+      ))
+    ]
+  )
+  yield (
+      api.test('error') +
+      api.buildbucket.simulated_cancel_output(
+        construct_batch_response(build_id=1785294945718829, status=common_pb2.CANCELED),
+        step_name='cancel_without_reason') +
+      api.buildbucket.simulated_cancel_output(
+        error_batch_response,
+        step_name='cancel_with_reason')
+  )