[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) — This file is a recipe demonstrating the buildbucket recipe module.
* [buildbucket:run/multi](#recipes-buildbucket_run_multi) — 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.
-  **@property**<br>— **def [bucket\_v1](/recipe_modules/buildbucket/api.py#887)(self):**
+  **@property**<br>— **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.
-  **@property**<br>— **def [build](/recipe_modules/buildbucket/api.py#117)(self):**
+  **@property**<br>— **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.
-  **@property**<br>— **def [build\_id](/recipe_modules/buildbucket/api.py#898)(self):**
+  **@property**<br>— **def [build\_id](/recipe_modules/buildbucket/api.py#939)(self):**
DEPRECATED, use build.id instead.
-  **@property**<br>— **def [build\_input](/recipe_modules/buildbucket/api.py#903)(self):**
+  **@property**<br>— **def [build\_input](/recipe_modules/buildbucket/api.py#944)(self):**
DEPRECATED, use build.input instead.
-— **def [build\_url](/recipe_modules/buildbucket/api.py#141)(self, host=None, build_id=None):**
+— **def [build\_url](/recipe_modules/buildbucket/api.py#139)(self, host=None, build_id=None):**
Returns url to a build. Defaults to current build.
-  **@property**<br>— **def [builder\_cache\_path](/recipe_modules/buildbucket/api.py#247)(self):**
+  **@property**<br>— **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
-  **@property**<br>— **def [builder\_id](/recipe_modules/buildbucket/api.py#908)(self):**
+  **@property**<br>— **def [builder\_id](/recipe_modules/buildbucket/api.py#949)(self):**
Deprecated. Use build.builder instead.
-  **@property**<br>— **def [builder\_name](/recipe_modules/buildbucket/api.py#136)(self):**
+  **@property**<br>— **def [builder\_name](/recipe_modules/buildbucket/api.py#134)(self):**
Returns builder name. Shortcut for `.build.builder.builder`.
-— **def [cancel\_build](/recipe_modules/buildbucket/api.py#637)(self, build_id, \*\*kwargs):**
+— **def [cancel\_build](/recipe_modules/buildbucket/api.py#635)(self, build_id, reason=' ', step_name=None):**
-— **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
+
+— **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.
-— **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):**
+— **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.
-— **def [get](/recipe_modules/buildbucket/api.py#683)(self, build_id, url_title_fn=None, step_name=None):**
+— **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.
-— **def [get\_build](/recipe_modules/buildbucket/api.py#700)(self, build_id, \*\*kwargs):**
+— **def [get\_build](/recipe_modules/buildbucket/api.py#732)(self, build_id, \*\*kwargs):**
DEPRECATED. Use get().
-— **def [get\_multi](/recipe_modules/buildbucket/api.py#640)(self, build_ids, url_title_fn=None, step_name=None, fields=DEFAULT_FIELDS):**
+— **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}.
-  **@property**<br>— **def [gitiles\_commit](/recipe_modules/buildbucket/api.py#146)(self):**
+  **@property**<br>— **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.
-  **@host.setter**<br>— **def [host](/recipe_modules/buildbucket/api.py#95)(self, value):**
+  **@host.setter**<br>— **def [host](/recipe_modules/buildbucket/api.py#93)(self, value):**
-— **def [is\_critical](/recipe_modules/buildbucket/api.py#157)(self, build=None):**
+— **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.
-— **def [put](/recipe_modules/buildbucket/api.py#537)(self, builds, \*\*kwargs):**
+— **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.
-— **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):**
+— **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.
-— **def [schedule](/recipe_modules/buildbucket/api.py#461)(self, schedule_build_requests, url_title_fn=None, step_name=None):**
+— **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.
-— **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):**
+— **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"]).
-— **def [search](/recipe_modules/buildbucket/api.py#572)(self, predicate, limit=None, url_title_fn=None, step_name=None, fields=DEFAULT_FIELDS):**
+— **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.
-— **def [set\_buildbucket\_host](/recipe_modules/buildbucket/api.py#99)(self, host):**
+— **def [set\_buildbucket\_host](/recipe_modules/buildbucket/api.py#97)(self, host):**
DEPRECATED. Use host property.
-— **def [set\_output\_gitiles\_commit](/recipe_modules/buildbucket/api.py#200)(self, gitiles_commit):**
+— **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.
-— **def [tags](/recipe_modules/buildbucket/api.py#243)(self, \*\*tags):**
+— **def [tags](/recipe_modules/buildbucket/api.py#241)(self, \*\*tags):**
Alias for tags in util.py. See doc there.
-  **@property**<br>— **def [tags\_for\_child\_build](/recipe_modules/buildbucket/api.py#163)(self):**
+  **@property**<br>— **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.
-— **def [use\_service\_account\_key](/recipe_modules/buildbucket/api.py#103)(self, key_path):**
+— **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)
— **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)
+
+— **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')
+ )