rollers: Check if roll is backwards
Backwards rolls never happened, but they caused infra failures when
running `git log $OLD..$NEW`. Now rollers check if the roll is in
reverse and marks the run as successful. This is needed because
occasionally LUCI config changes result in luci-scheduler thinking a
roller is new and triggering 30 rolls that all fail. Now they'll still
be triggered but they won't do anything and then they'll pass.
Added this logic to the roll_message module, which is now renamed to
roll_util.
Tested by retriggering a past successful roll.
$ led get-build 8868516989406765792 | led edit-recipe-bundle | \
led edit -p 'dry_run=true' | led launch
LUCI UI: https://ci.chromium.org/swarming/task/4ecf5393d5a23d10
Also tested by pausing a roller and manually triggering after another CL
went in.
$ led get-builder luci.pigweed.ci:pigweed-stm32-roller | \
led edit-recipe-bundle | led edit -p dry_run=true | led launch
LUCI UI: https://ci.chromium.org/swarming/task/4ed09708b7433d10
Change-Id: I057272a5221626312a96b195eccdaf9dd8dc053d
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/18683
Reviewed-by: Oliver Newman <olivernewman@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
diff --git a/recipe_modules/roll_util/api.py b/recipe_modules/roll_util/api.py
new file mode 100644
index 0000000..61b6188
--- /dev/null
+++ b/recipe_modules/roll_util/api.py
@@ -0,0 +1,198 @@
+# Copyright 2020 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Utility functions for rollers."""
+
+import pprint
+import re
+
+import attr
+from recipe_engine import recipe_api
+
+# If we're embedding the original commit message, prepend 'Original-' to lines
+# which begin with these tags.
+ESCAPE_TAGS = [
+ 'Bug:',
+ 'Fixed:',
+ 'Reviewed-on:',
+]
+
+# If we're embedding the original commit message, remove lines which contain
+# these tags.
+FILTER_TAGS = [
+ 'API-Review:',
+ 'Acked-by:',
+ 'CC:',
+ 'CQ-Do-Not-Cancel-Tryjobs:',
+ 'Change-Id:',
+ 'Commit-Queue:',
+ 'Reviewed-by:',
+ 'Signed-off-by:',
+ 'Testability-Review:',
+ 'Tested-by:',
+]
+
+
+def _sanitize_message(message):
+ """Sanitize lines of a commit message.
+
+ Prepend 'Original-' to lines which begin with ESCAPE_TAGS. Filter
+ out lines which begin with FILTER_TAGS.
+ """
+ return '\n'.join(
+ "Original-" + line
+ if any((line.startswith(tag) for tag in ESCAPE_TAGS))
+ else line
+ for line in message.splitlines()
+ if not any((tag in line for tag in FILTER_TAGS))
+ )
+
+
+@attr.s
+class Commit(object):
+ hash = attr.ib()
+ message = attr.ib()
+
+
+def _is_hash(value):
+ return re.match(r'^[0-9a-fA-F]{40}', value)
+
+
+class RollUtilApi(recipe_api.RecipeApi):
+
+ def _single_commit_roll_message(self, project_name, commit,
+ old_revision, new_revision):
+ template = """
+[roll {project_name}] {sanitized_message}
+
+Rolled-Commits: {old_revision:.15}..{new_revision:.15}
+CQ-Do-Not-Cancel-Tryjobs: true
+ """.strip()
+
+ kwargs = {
+ 'project_name': project_name,
+ 'original_message': commit.message,
+ 'sanitized_message': _sanitize_message(commit.message),
+ 'old_revision': old_revision,
+ 'new_revision': new_revision,
+ }
+
+ message = template.format(**kwargs)
+
+ with self.m.step.nest('message') as pres:
+ pres.logs['template'] = template
+ pres.logs['kwargs'] = pprint.pformat(kwargs)
+ pres.logs['message'] = message
+
+ return message
+
+ def _multiple_commits_roll_message(self, project_name, commits,
+ old_revision, new_revision):
+ template = """
+[{project_name}] Roll {number} commits
+
+{one_liners}
+
+Rolled-Commits: {old_revision:.15}..{new_revision:.15}
+CQ-Do-Not-Cancel-Tryjobs: true
+ """.strip()
+
+ one_liners = [
+ '{:.15} {}'.format(commit.hash, commit.message.splitlines()[0][:50])
+ for commit in commits
+ ]
+
+ number = len(commits)
+ if not _is_hash(old_revision):
+ number = 'multiple'
+ one_liners.append('...')
+
+ kwargs = {
+ 'project_name': project_name,
+ 'number': number,
+ 'one_liners': '\n'.join(one_liners),
+ 'old_revision': old_revision,
+ 'new_revision': new_revision,
+ }
+
+ message = template.format(**kwargs)
+
+ with self.m.step.nest('message') as pres:
+ pres.logs['template'] = template
+ pres.logs['kwargs'] = pprint.pformat(kwargs)
+ pres.logs['message'] = message
+
+ return message
+
+ def _commits(self, proj_dir, old_revision, new_revision):
+ with self.m.context(cwd=proj_dir):
+ commits = []
+ if _is_hash(old_revision):
+ base = old_revision
+ else:
+ base = '{}~5'.format(new_revision)
+
+ for commit in self.m.git(
+ 'log', '{}..{}'.format(base, new_revision),
+ '--pretty=format:%H %B', '-z',
+ stdout=self.m.raw_io.output()).stdout.strip('\0').split('\0'):
+ hash, message = commit.split(' ', 1)
+ commits.append(Commit(hash, message))
+ return commits
+
+ def message(self, project_name, proj_dir, old_revision, new_revision):
+ with self.m.step.nest('roll message'):
+ commits = self._commits(proj_dir, old_revision, new_revision)
+
+ if len(commits) > 1:
+ return self._multiple_commits_roll_message(
+ project_name, commits, old_revision, new_revision)
+
+ else:
+ return self._single_commit_roll_message(
+ project_name, commits[0], old_revision, new_revision)
+
+ def check_roll_direction(self, git_dir, old, new,
+ name='check roll direction'):
+ """Return if old is an ancestor of new (i.e., the roll moves forward)."""
+ if old == new:
+ with self.m.step.nest(name) as pres:
+ pres.step_summary_text = 'up-to-date'
+ return False
+
+ with self.m.context(git_dir):
+ step = self.m.git(
+ 'merge-base',
+ '--is-ancestor',
+ old,
+ new,
+ name=name,
+ ok_ret=(0, 1),
+ )
+
+ step.presentation.step_summary_text = 'backward'
+ if step.exc_result.retcode == 0:
+ step.presentation.step_summary_text = 'forward'
+
+ return step.exc_result.retcode == 0
+
+ def backwards_roll_step(self, remote, old_revision, new_revision):
+ with self.m.step.nest('cancelling roll') as pres:
+ fmt = ('not updating from {old} to {new} because {old} is newer '
+ 'than {new}')
+ if old_revision == new_revision:
+ fmt = 'not updating from {old} to {new} because they are identical'
+ pres.step_summary_text = fmt.format(old=old_revision[0:7],
+ new=new_revision[0:7])
+ pres.links[old_revision] = '{}/+/{}'.format(remote, old_revision)
+ pres.links[new_revision] = '{}/+/{}'.format(remote, new_revision)