diff --git a/recipes/README.recipes.md b/recipes/README.recipes.md
index b3d9060ba..a7760f488 100644
--- a/recipes/README.recipes.md
+++ b/recipes/README.recipes.md
@@ -36,6 +36,8 @@
* [infra_paths:examples/full](#recipes-infra_paths_examples_full)
* [osx_sdk:examples/full](#recipes-osx_sdk_examples_full)
* [presubmit:examples/full](#recipes-presubmit_examples_full)
+ * [presubmit:tests/execute](#recipes-presubmit_tests_execute)
+ * [presubmit:tests/prepare](#recipes-presubmit_tests_prepare)
* [tryserver:examples/full](#recipes-tryserver_examples_full)
* [tryserver:tests/gerrit_change_fetch_ref](#recipes-tryserver_tests_gerrit_change_fetch_ref)
* [windows_sdk:examples/full](#recipes-windows_sdk_examples_full)
@@ -739,15 +741,39 @@ Raises:
StepFailure or InfraFailure.
### *recipe_modules* / [presubmit](/recipes/recipe_modules/presubmit)
-[DEPS](/recipes/recipe_modules/presubmit/__init__.py#1): [depot\_tools](#recipe_modules-depot_tools), [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/python][recipe_engine/recipe_modules/python], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+[DEPS](/recipes/recipe_modules/presubmit/__init__.py#11): [bot\_update](#recipe_modules-bot_update), [depot\_tools](#recipe_modules-depot_tools), [gclient](#recipe_modules-gclient), [git](#recipe_modules-git), [tryserver](#recipe_modules-tryserver), [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/cq][recipe_engine/recipe_modules/cq], [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/python][recipe_engine/recipe_modules/python], [recipe\_engine/step][recipe_engine/recipe_modules/step]
-#### **class [PresubmitApi](/recipes/recipe_modules/presubmit/api.py#7)([RecipeApi][recipe_engine/wkt/RecipeApi]):**
+#### **class [PresubmitApi](/recipes/recipe_modules/presubmit/api.py#11)([RecipeApi][recipe_engine/wkt/RecipeApi]):**
-— **def [\_\_call\_\_](/recipes/recipe_modules/presubmit/api.py#12)(self, \*args, \*\*kwargs):**
+— **def [\_\_call\_\_](/recipes/recipe_modules/presubmit/api.py#27)(self, \*args, \*\*kwargs):**
Return a presubmit step.
- **@property**
— **def [presubmit\_support\_path](/recipes/recipe_modules/presubmit/api.py#8)(self):**
+— **def [execute](/recipes/recipe_modules/presubmit/api.py#74)(self, bot_update_step):**
+
+Runs presubmit and sets summary markdown if applicable.
+
+Args:
+ bot_update_step: the StepResult from a previously executed bot_update step.
+Returns:
+ a RawResult object, suitable for being returned from RunSteps.
+
+— **def [prepare](/recipes/recipe_modules/presubmit/api.py#39)(self):**
+
+Set up a presubmit run.
+
+This includes:
+
+ - setting up the checkout w/ bot_update
+ - locally committing the applied patch
+ - running hooks, if requested
+
+This expects the gclient configuration to already have been set.
+
+Returns:
+ the StepResult from the bot_update step.
+
+ **@property**
— **def [presubmit\_support\_path](/recipes/recipe_modules/presubmit/api.py#23)(self):**
### *recipe_modules* / [tryserver](/recipes/recipe_modules/tryserver)
[DEPS](/recipes/recipe_modules/tryserver/__init__.py#5): [gerrit](#recipe_modules-gerrit), [git](#recipe_modules-git), [git\_cl](#recipe_modules-git_cl), [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/context][recipe_engine/recipe_modules/context], [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/step][recipe_engine/recipe_modules/step]
@@ -987,6 +1013,16 @@ Move things around in a loop!
[DEPS](/recipes/recipe_modules/presubmit/examples/full.py#5): [presubmit](#recipe_modules-presubmit), [recipe\_engine/json][recipe_engine/recipe_modules/json]
— **def [RunSteps](/recipes/recipe_modules/presubmit/examples/full.py#11)(api):**
+### *recipes* / [presubmit:tests/execute](/recipes/recipe_modules/presubmit/tests/execute.py)
+
+[DEPS](/recipes/recipe_modules/presubmit/tests/execute.py#10): [gclient](#recipe_modules-gclient), [presubmit](#recipe_modules-presubmit), [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/cq][recipe_engine/recipe_modules/cq], [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/runtime][recipe_engine/recipe_modules/runtime]
+
+— **def [RunSteps](/recipes/recipe_modules/presubmit/tests/execute.py#29)(api, patch_project, patch_repository_url):**
+### *recipes* / [presubmit:tests/prepare](/recipes/recipe_modules/presubmit/tests/prepare.py)
+
+[DEPS](/recipes/recipe_modules/presubmit/tests/prepare.py#9): [gclient](#recipe_modules-gclient), [presubmit](#recipe_modules-presubmit), [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/runtime][recipe_engine/recipe_modules/runtime]
+
+— **def [RunSteps](/recipes/recipe_modules/presubmit/tests/prepare.py#26)(api, patch_project, patch_repository_url):**
### *recipes* / [tryserver:examples/full](/recipes/recipe_modules/tryserver/examples/full.py)
[DEPS](/recipes/recipe_modules/tryserver/examples/full.py#5): [gerrit](#recipe_modules-gerrit), [tryserver](#recipe_modules-tryserver), [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [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/step][recipe_engine/recipe_modules/step]
@@ -1007,6 +1043,7 @@ Move things around in a loop!
[recipe_engine/recipe_modules/cipd]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/9fc4304dd3a91f553108a76aa90c70c27891601a/README.recipes.md#recipe_modules-cipd
[recipe_engine/recipe_modules/commit_position]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/9fc4304dd3a91f553108a76aa90c70c27891601a/README.recipes.md#recipe_modules-commit_position
[recipe_engine/recipe_modules/context]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/9fc4304dd3a91f553108a76aa90c70c27891601a/README.recipes.md#recipe_modules-context
+[recipe_engine/recipe_modules/cq]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/9fc4304dd3a91f553108a76aa90c70c27891601a/README.recipes.md#recipe_modules-cq
[recipe_engine/recipe_modules/file]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/9fc4304dd3a91f553108a76aa90c70c27891601a/README.recipes.md#recipe_modules-file
[recipe_engine/recipe_modules/json]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/9fc4304dd3a91f553108a76aa90c70c27891601a/README.recipes.md#recipe_modules-json
[recipe_engine/recipe_modules/path]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/9fc4304dd3a91f553108a76aa90c70c27891601a/README.recipes.md#recipe_modules-path
diff --git a/recipes/recipe_modules/presubmit/__init__.py b/recipes/recipe_modules/presubmit/__init__.py
index d63916b83..233484bb1 100644
--- a/recipes/recipe_modules/presubmit/__init__.py
+++ b/recipes/recipe_modules/presubmit/__init__.py
@@ -1,8 +1,27 @@
+# Copyright 2019 The Chromium 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.config import ConfigGroup, Single
+from recipe_engine.recipe_api import Property
+
+from PB.recipe_modules.depot_tools.presubmit import properties
+
+
DEPS = [
+ 'bot_update',
'depot_tools',
- 'recipe_engine/json',
+ 'gclient',
+ 'git',
'recipe_engine/context',
+ 'recipe_engine/cq',
+ 'recipe_engine/json',
'recipe_engine/path',
+ 'recipe_engine/properties',
'recipe_engine/python',
'recipe_engine/step',
+ 'tryserver',
]
+
+
+PROPERTIES = properties.InputProperties
diff --git a/recipes/recipe_modules/presubmit/api.py b/recipes/recipe_modules/presubmit/api.py
index 0faea9b9f..166bebc37 100644
--- a/recipes/recipe_modules/presubmit/api.py
+++ b/recipes/recipe_modules/presubmit/api.py
@@ -4,7 +4,22 @@
from recipe_engine import recipe_api
+from PB.recipe_engine import result as result_pb2
+from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
+
+
class PresubmitApi(recipe_api.RecipeApi):
+
+ def __init__(self, properties, **kwargs):
+ super(PresubmitApi, self).__init__(**kwargs)
+
+ self._runhooks = properties.runhooks
+ # 8 minutes seems like a reasonable upper bound on presubmit timings.
+ # According to event mon data we have, it seems like anything longer than
+ # this is a bug, and should just instant fail.
+ self._timeout_s = properties.timeout_s
+ self._vpython_spec_path = properties.vpython_spec_path
+
@property
def presubmit_support_path(self):
return self.repo_resource('presubmit_support.py')
@@ -20,3 +35,215 @@ class PresubmitApi(recipe_api.RecipeApi):
step_data = self.m.python(
name, self.presubmit_support_path, presubmit_args, **kwargs)
return step_data.json.output
+
+ def prepare(self):
+ """Set up a presubmit run.
+
+ This includes:
+
+ - setting up the checkout w/ bot_update
+ - locally committing the applied patch
+ - running hooks, if requested
+
+ This expects the gclient configuration to already have been set.
+
+ Returns:
+ the StepResult from the bot_update step.
+ """
+ # Expect callers to have already set up their gclient configuration.
+
+ bot_update_step = self.m.bot_update.ensure_checkout()
+ relative_root = self.m.gclient.get_gerrit_patch_root().rstrip('/')
+
+ abs_root = self.m.context.cwd.join(relative_root)
+ with self.m.context(cwd=abs_root):
+ # TODO(unowned): Consider either:
+ # - extracting user name & email address from the issue, or
+ # - using a dedicated and clearly nonexistent name/email address
+ self.m.git('-c', 'user.email=commit-bot@chromium.org',
+ '-c', 'user.name=The Commit Bot',
+ 'commit', '-a', '-m', 'Committed patch',
+ name='commit-git-patch', infra_step=False)
+
+ if self._runhooks:
+ with self.m.context(cwd=self.m.path['checkout']):
+ self.m.gclient.runhooks()
+
+ return bot_update_step
+
+ def execute(self, bot_update_step):
+ """Runs presubmit and sets summary markdown if applicable.
+
+ Args:
+ bot_update_step: the StepResult from a previously executed bot_update step.
+ Returns:
+ a RawResult object, suitable for being returned from RunSteps.
+ """
+ relative_root = self.m.gclient.get_gerrit_patch_root().rstrip('/')
+ abs_root = self.m.context.cwd.join(relative_root)
+ got_revision_properties = self.m.bot_update.get_project_revision_properties(
+ # Replace path.sep with '/', since most recipes are written assuming '/'
+ # as the delimiter. This breaks on windows otherwise.
+ relative_root.replace(self.m.path.sep, '/'), self.m.gclient.c)
+ upstream = bot_update_step.json.output['properties'].get(
+ got_revision_properties[0])
+
+ presubmit_args = [
+ '--issue', self.m.tryserver.gerrit_change.change,
+ '--patchset', self.m.tryserver.gerrit_change.patchset,
+ '--gerrit_url', 'https://%s' % self.m.tryserver.gerrit_change.host,
+ '--gerrit_fetch',
+ ]
+ if self.m.cq.state == self.m.cq.DRY:
+ presubmit_args.append('--dry_run')
+
+ presubmit_args.extend([
+ '--root', abs_root,
+ '--commit',
+ '--verbose', '--verbose',
+ '--skip_canned', 'CheckTreeIsOpen',
+ '--skip_canned', 'CheckBuildbotPendingBuilds',
+ '--upstream', upstream, # '' if not in bot_update mode.
+ ])
+
+ venv = None
+ # TODO(iannucci): verify that presubmit_support.py correctly finds and
+ # uses .vpython files, then remove this configuration.
+ if self._vpython_spec_path:
+ venv = abs_root.join(self._vpython_spec_path)
+
+ raw_result = result_pb2.RawResult()
+ step_json = self(
+ *presubmit_args,
+ venv=venv, timeout=self._timeout_s,
+ # ok_ret='any' causes all exceptions to be ignored in this step
+ ok_ret='any')
+ # Set recipe result values
+ if step_json:
+ raw_result.summary_markdown = _createSummaryMarkdown(step_json)
+
+ retcode = self.m.step.active_result.retcode
+ if retcode == 0:
+ raw_result.status = common_pb2.SUCCESS
+ return raw_result
+
+ self.m.step.active_result.presentation.status = 'FAILURE'
+ if self.m.step.active_result.exc_result.had_timeout:
+ # TODO(iannucci): Shouldn't we also mark failure on timeouts?
+ raw_result.summary_markdown += (
+ '\n\nTimeout occurred during presubmit step.')
+ if retcode == 1:
+ raw_result.status = common_pb2.FAILURE
+ self.m.tryserver.set_test_failure_tryjob_result()
+ else:
+ raw_result.status = common_pb2.INFRA_FAILURE
+ self.m.tryserver.set_invalid_test_results_tryjob_result()
+ # Handle unexpected errors not caught by json output
+ if raw_result.summary_markdown == '':
+ raw_result.status = common_pb2.INFRA_FAILURE
+ raw_result.summary_markdown = (
+ 'Something unexpected occurred'
+ ' while running presubmit checks.'
+ ' Please [file a bug](https://bugs.chromium.org'
+ '/p/chromium/issues/entry?components='
+ 'Infra%3EClient%3EChrome&status=Untriaged)'
+ )
+ return raw_result
+
+
+def _limitSize(message_list, char_limit=450):
+ """Returns a list of strings within a certain character length.
+
+ Args:
+ * message_list (List[str]) - The message to truncate as a list
+ of lines (without line endings).
+ """
+ hint = ('**The complete output can be'
+ ' found at the bottom of the presubmit stdout.**')
+ char_count = 0
+ for index, message in enumerate(message_list):
+ char_count += len(message)
+ if char_count > char_limit:
+ total_errors = len(message_list)
+ oversized_msg = ('**Error size > %d chars, '
+ 'there are %d more error(s) (%d total)**') % (
+ char_limit, total_errors - index, total_errors
+ )
+ if index == 0:
+ # Show at minimum part of the first error message
+ first_message = message_list[index].replace('\n\n', '\n')
+ return ['\n\n'.join(
+ _limitSize(first_message.splitlines())
+ )
+ ]
+ return message_list[:index] + [oversized_msg, hint]
+ return message_list
+
+
+def _createSummaryMarkdown(step_json):
+ """Returns a string with data on errors, warnings, and notifications.
+
+ Extracts the number of errors, warnings and notifications
+ from the dictionary(step_json).
+
+ Then it lists all the errors line by line.
+
+ Args:
+ * step_json = {
+ 'errors': [
+ {
+ 'message': string,
+ 'long_text': string,
+ 'items: [string],
+ 'fatal': boolean
+ }
+ ],
+ 'notifications': [
+ {
+ 'message': string,
+ 'long_text': string,
+ 'items: [string],
+ 'fatal': boolean
+ }
+ ],
+ 'warnings': [
+ {
+ 'message': string,
+ 'long_text': string,
+ 'items: [string],
+ 'fatal': boolean
+ }
+ ]
+ }
+ """
+ errors = step_json['errors']
+ warning_count = len(step_json['warnings'])
+ notif_count = len(step_json['notifications'])
+ description = (
+ 'There are %d error(s), %d warning(s),'
+ ' and %d notifications(s). Here are the errors:') % (
+ len(errors), warning_count, notif_count
+ )
+ error_messages = []
+
+ for error in errors:
+ # markdown represents new lines with 2 spaces
+ # replacing the \n with \n\n because \n gets replaced with an empty space.
+ # This way it will work on both markdown and plain text.
+ error_messages.append(
+ '**ERROR**\n\n%s\n\n%s' % (
+ error['message'].replace('\n', '\n\n'),
+ error['long_text'].replace('\n', '\n\n'))
+ )
+
+ error_messages = _limitSize(error_messages)
+ # Description is not counted in the total message size.
+ # It is inserted afterward to ensure it is the first message seen.
+ error_messages.insert(0, description)
+ if warning_count or notif_count:
+ error_messages.append(
+ ('To see notifications and warnings,'
+ ' look at the stdout of the presubmit step.')
+ )
+ return '\n\n'.join(error_messages)
+
diff --git a/recipes/recipe_modules/presubmit/properties.proto b/recipes/recipe_modules/presubmit/properties.proto
new file mode 100644
index 000000000..737fa44ca
--- /dev/null
+++ b/recipes/recipe_modules/presubmit/properties.proto
@@ -0,0 +1,15 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+syntax = "proto3";
+package recipe_modules.depot_tools.presubmit;
+
+message InputProperties {
+ // Whether gclient hooks should be run when setting up the checkout.
+ bool runhooks = 1;
+ // Timeout for presubmit execution, in seconds.
+ int32 timeout_s = 2;
+ // The path to the vpython spec to use, relative to the repository root.
+ string vpython_spec_path = 3;
+}
diff --git a/recipes/recipe_modules/presubmit/test_api.py b/recipes/recipe_modules/presubmit/test_api.py
new file mode 100644
index 000000000..f6512d4da
--- /dev/null
+++ b/recipes/recipe_modules/presubmit/test_api.py
@@ -0,0 +1,20 @@
+# Copyright 2019 The Chromium 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_test_api
+
+from PB.recipe_modules.depot_tools.presubmit import properties
+
+class PresubmitTestApi(recipe_test_api.RecipeTestApi):
+
+ def __call__(self, runhooks=False, timeout_s=480, vpython_spec_path=None):
+ return self.m.properties(
+ **{
+ '$depot_tools/presubmit': properties.InputProperties(
+ runhooks=runhooks,
+ timeout_s=timeout_s,
+ vpython_spec_path=vpython_spec_path,
+ ),
+ }
+ )
diff --git a/recipes/recipe_modules/presubmit/tests/execute.py b/recipes/recipe_modules/presubmit/tests/execute.py
new file mode 100644
index 000000000..863c296ba
--- /dev/null
+++ b/recipes/recipe_modules/presubmit/tests/execute.py
@@ -0,0 +1,260 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import textwrap
+
+from recipe_engine import post_process
+from recipe_engine import recipe_api
+
+DEPS = [
+ 'gclient',
+ 'presubmit',
+ 'recipe_engine/buildbucket',
+ 'recipe_engine/context',
+ 'recipe_engine/cq',
+ 'recipe_engine/json',
+ 'recipe_engine/path',
+ 'recipe_engine/properties',
+ 'recipe_engine/runtime',
+]
+
+
+PROPERTIES = {
+ 'patch_project': recipe_api.Property(None),
+ 'patch_repository_url': recipe_api.Property(None),
+}
+
+
+def RunSteps(api, patch_project, patch_repository_url):
+ api.gclient.set_config('infra')
+ with api.context(cwd=api.path['cache'].join('builder')):
+ bot_update_step = api.presubmit.prepare()
+ return api.presubmit.execute(bot_update_step)
+
+
+def GenTests(api):
+ yield (
+ api.test('success') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.step_data(
+ 'presubmit',
+ api.json.output({'errors': [], 'notifications': [], 'warnings': []}),
+ ) +
+ api.post_process(post_process.StatusSuccess) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ yield (
+ api.test('cq_dry_run') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.cq(dry_run=True) +
+ api.post_process(post_process.StatusSuccess) +
+ api.post_process(post_process.StepCommandContains, 'presubmit', ['--dry_run']) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ yield (
+ api.test('root_vpython') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.presubmit(vpython_spec_path='.vpython') +
+ api.post_process(post_process.StatusSuccess) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ yield (
+ api.test('timeout') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.presubmit(timeout_s=600) +
+ api.step_data(
+ 'presubmit',
+ api.json.output({'errors': [], 'notifications': [], 'warnings': []}),
+ times_out_after=1200,
+ ) +
+ api.post_process(post_process.StatusFailure) +
+ api.post_process(
+ post_process.ResultReason,
+ (u'There are 0 error(s), 0 warning(s), and 0 notifications(s).'
+ ' Here are the errors:'
+ '\n\nTimeout occurred during presubmit step.')) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ yield (
+ api.test('failure') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.step_data('presubmit', api.json.output(
+ {
+ 'errors': [
+ {
+ 'message': 'Missing LGTM',
+ 'long_text': 'Here are some suggested OWNERS: fake@',
+ 'items': [],
+ 'fatal': True
+ },
+ {
+ 'message': 'Syntax error in fake.py',
+ 'long_text': 'Expected "," after item in list',
+ 'items': [],
+ 'fatal': True
+ }
+ ],
+ 'notifications': [
+ {
+ 'message': 'If there is a bug associated please add it.',
+ 'long_text': '',
+ 'items': [],
+ 'fatal': False
+ }
+ ],
+ 'warnings': [
+ {
+ 'message': 'Line 100 has more than 80 characters',
+ 'long_text': '',
+ 'items': [],
+ 'fatal': False
+ }
+ ]
+ }, retcode=1)
+ ) +
+ api.post_process(post_process.StatusFailure) +
+ api.post_process(post_process.ResultReason, textwrap.dedent(u'''
+ There are 2 error(s), 1 warning(s), and 1 notifications(s). Here are the errors:
+
+ **ERROR**
+
+ Missing LGTM
+
+ Here are some suggested OWNERS: fake@
+
+ **ERROR**
+
+ Syntax error in fake.py
+
+ Expected "," after item in list
+
+ To see notifications and warnings, look at the stdout of the presubmit step.
+ ''').strip()
+ ) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ long_message = (u'Here are some suggested OWNERS:' +
+ u'\nreallyLongFakeAccountNameEmail@chromium.org' * 10)
+ yield (
+ api.test('failure-long-message') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.step_data('presubmit', api.json.output(
+ {
+ 'errors': [
+ {
+ 'message': 'Missing LGTM',
+ 'long_text': long_message,
+ 'items': [],
+ 'fatal': True
+ }
+ ],
+ 'notifications': [],
+ 'warnings': []
+ }, retcode=1)
+ ) +
+ api.post_process(post_process.StatusFailure) +
+ api.post_process(post_process.ResultReason, textwrap.dedent('''
+ There are 1 error(s), 0 warning(s), and 0 notifications(s). Here are the errors:
+
+ **ERROR**
+
+ Missing LGTM
+
+ Here are some suggested OWNERS:
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ reallyLongFakeAccountNameEmail@chromium.org
+
+ **Error size > 450 chars, there are 1 more error(s) (13 total)**
+
+ **The complete output can be found at the bottom of the presubmit stdout.**
+ ''').strip()
+ ) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ yield (
+ api.test('infra-failure') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.step_data('presubmit', api.json.output(
+ {
+ 'errors': [
+ {
+ 'message': 'Infra Failure',
+ 'long_text': '',
+ 'items': [],
+ 'fatal': True
+ }
+ ],
+ 'notifications': [],
+ 'warnings': []
+ }, retcode=2)
+ ) +
+ api.post_process(post_process.StatusFailure) +
+ api.post_process(post_process.ResultReason, textwrap.dedent(u'''
+ There are 1 error(s), 0 warning(s), and 0 notifications(s). Here are the errors:
+
+ **ERROR**
+
+ Infra Failure
+
+ ''').lstrip()
+ ) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ bug_msg = (
+ 'Something unexpected occurred'
+ ' while running presubmit checks.'
+ ' Please [file a bug](https://bugs.chromium.org'
+ '/p/chromium/issues/entry?components='
+ 'Infra%3EClient%3EChrome&status=Untriaged)'
+ )
+ yield (
+ api.test('failure-no-json') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.step_data('presubmit', api.json.output(None, retcode=1)) +
+ api.post_process(post_process.StatusFailure) +
+ api.post_process(post_process.ResultReason, bug_msg) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ yield (
+ api.test('infra-failure-no-json') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.step_data('presubmit', api.json.output(None, retcode=2)) +
+ api.post_process(post_process.StatusFailure) +
+ api.post_process(post_process.ResultReason, bug_msg) +
+ api.post_process(post_process.DropExpectation)
+ )
+
diff --git a/recipes/recipe_modules/presubmit/tests/prepare.py b/recipes/recipe_modules/presubmit/tests/prepare.py
new file mode 100644
index 000000000..83cc87bb2
--- /dev/null
+++ b/recipes/recipe_modules/presubmit/tests/prepare.py
@@ -0,0 +1,49 @@
+# Copyright 2019 The Chromium 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 post_process
+from recipe_engine import recipe_api
+
+
+DEPS = [
+ 'gclient',
+ 'presubmit',
+ 'recipe_engine/buildbucket',
+ 'recipe_engine/context',
+ 'recipe_engine/path',
+ 'recipe_engine/properties',
+ 'recipe_engine/runtime',
+]
+
+
+PROPERTIES = {
+ 'patch_project': recipe_api.Property(None),
+ 'patch_repository_url': recipe_api.Property(None),
+}
+
+
+def RunSteps(api, patch_project, patch_repository_url):
+ api.gclient.set_config('infra')
+ with api.context(cwd=api.path['cache'].join('builder')):
+ bot_update_step = api.presubmit.prepare()
+
+
+def GenTests(api):
+ yield (
+ api.test('basic') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.post_process(post_process.StatusSuccess) +
+ api.post_process(post_process.DropExpectation)
+ )
+
+ yield (
+ api.test('runhooks') +
+ api.runtime(is_experimental=False, is_luci=True) +
+ api.buildbucket.try_build(project='infra') +
+ api.presubmit(runhooks=True) +
+ api.post_process(post_process.MustRun, 'gclient runhooks') +
+ api.post_process(post_process.StatusSuccess) +
+ api.post_process(post_process.DropExpectation)
+ )