blob: 0240e7373a99c0d2d583b2223aa4989818d738ca [file] [log] [blame]
# Copyright 2018 The Fuchsia 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 attr
from recipe_engine import recipe_api
UPSTREAM_REF = 'master'
PROJECT_LOG_FORMAT = """\
{project} {old}..{new} ({count} commits)
{commits}
"""
PACKAGE_LOG_FORMAT = """\
{package} {old}..{new}
"""
class CQResult(object):
"""Represents the result of waiting for CQ to complete."""
# CQ completed successfully.
SUCCESS = 1
# CQ tryjobs failed.
FAILURE = 2
# Timed out waiting for CQ to finish.
TIMEOUT = 3
# The CL was manually abandoned.
ABANDONED = 4
@attr.s
class GerritChange(object):
id = attr.ib(type=str)
project = attr.ib(type=str)
upstream_ref = attr.ib(type=str)
def __attrs_post_init__(self):
# pylint: disable=attribute-defined-outside-init
self._full_id = '~'.join((self.project, self.upstream_ref, self.id))
# pylint: enable=attribute-defined-outside-init
@property
def full_id(self):
"""Represents the full (unique) change ID for this change and is necessary
for any Gerrit API calls.
"""
return self._full_id
class AutoRollerApi(recipe_api.RecipeApi):
"""API for writing auto-roller recipes."""
def __init__(self, poll_interval_secs, poll_timeout_secs, *args, **kwargs):
# poll_interval_secs and poll_timeout_secs are input properties which come
# from __init__.PROPERTIES in this directory.
super(AutoRollerApi, self).__init__(*args, **kwargs)
# The name of the link to the Gerrit change created for a roll. This is
# displayed in the CQ failure error message to help others understand where
# to look when debugging failed rolls.
self._gerrit_link_name = 'gerrit link'
self._poll_interval_secs = poll_interval_secs
self._poll_timeout_secs = poll_timeout_secs
@property
def poll_interval_secs(self):
"""Returns how many seconds roll() will wait in between each poll.
Defined by the input property with the same name.
"""
return self._poll_interval_secs
@property
def poll_timeout_secs(self):
"""Returns how many seconds roll() will poll for.
Defined by the input property with the same name.
"""
return self._poll_timeout_secs
def _repo_has_uncommitted_files(self, repo_dir, check_untracked):
"""Checks whether the git repository at repo_dir has any changes.
Args:
repo_dir (Path): Path to the git repository.
check_untracked (bool): Whether to include untracked files in the check.
Returns:
True if there are, and False if not.
"""
args = ['--modified', '--deleted', '--exclude-standard']
if check_untracked:
args.append('--others')
with self.m.context(cwd=repo_dir):
step_result = self.m.git(
'ls-files',
*args,
name='check for no-op commit',
stdout=self.m.raw_io.output(),
step_test_data=lambda: self.m.raw_io.test_api.stream_output('hello'))
step_result.presentation.logs['stdout'] = step_result.stdout.split('\n')
return bool(step_result.stdout.strip())
def _create_and_push_change(self,
gerrit_project,
repo_dir,
commit_message,
commit_untracked,
upstream_ref=UPSTREAM_REF):
"""Creates a Gerrit change containing modified files under repo_dir.
Returns the full (unique) Gerrit change ID for the newly created change.
"""
with self.m.context(cwd=repo_dir):
# Generate a change ID from this change based on the diff by first running
# `git diff` and extracting the output. Then, include information about the
# committer. Finally, execute `git hash-object` on that output.
#
# We generate our own change ID because it allows us to create the Gerrit
# change via `git push` as opposed to using the API to create a change and
# then pushing the actual git commit. Creating a change via the API can
# create a race condition, as Gerrit's backend propagates information
# asynchronously, and so `git push` may fail. This is generally handled
# with retries, and so for the gerrit recipe module this is OK since the
# underlying client can retry, but we cannot easily retry a git push.
#
# Note that the Gerrit allows one to generate their own
# change ID of any form, we simply choose to loosely follow Gerrit's
# default which is a hash of the commit information (such as the committer's
# personal information) and the diff itself.
#
# Gerrit-generated change IDs are 40-character hex digests prefixed with
# "I", so we do that here too.
# Call `git add --all --intend-to-add` to move any new files in the
# working tree into a 'tracked' state. `git diff` does not compare
# 'untracked' files as there is no base in the index.
if commit_untracked:
self.m.git('add', '--all', '--intent-to-add')
# Compute the git diff for the uncommitted changes in the tree.
diff_step = self.m.git(
'diff',
stdout=self.m.raw_io.output(),
step_test_data=lambda: self.m.raw_io.test_api.stream_output('a diff'))
# TODO(mknyszek): Include the committer's personal information (such as
# the service account email for this recipe run) to the value that's
# hashed in order to reduce the chance that two change IDs conflict. It's OK
# not to include it now because human-created changes will always have the
# change ID based on additional info and rollers do not have conflicting diffs
# (and do not conflict with themselves).
# Use the hash of the diff and commit information as the change ID so
# that this CL will collide with any older identical CLs.
hash_step = self.m.git(
'hash-object',
self.m.raw_io.input(diff_step.stdout),
stdout=self.m.raw_io.output(),
step_test_data=lambda: self.m.raw_io.test_api.stream_output('abc123'))
change_id = 'I%s' % hash_step.stdout.strip()
# Update message with a Change-Id line and push the roll.
updated_message = commit_message + ('\nChange-Id: %s\n' % change_id)
self.m.git.commit(message=updated_message, all_tracked=True)
push_step = self.m.git.push(
'HEAD:refs/for/%s' % upstream_ref, ok_ret='any')
if push_step.retcode != 0:
# This is probably a change ID collision - there's already an identical
# older CL that failed CQ and got abandoned by the roller, so revive it
# and try CQ again rather than creating a new CL.
push_step.presentation.step_summary_text = 'rejected by gerrit'
push_step.presentation.step_text = (
'\nChange is identical to a previous roll that the roller '
'abandoned because it failed in CQ.')
self.m.gerrit.restore_change('restoring previous roll attempt',
change_id)
# Surface a link to the change by querying gerrit for the change ID. If
# it's the only commit with that change ID (highly likely) then it will
# open it automatically. Unfortunately the full change ID doesn't
# exhibit this same behavior, so we avoid using it.
self.m.step.active_result.presentation.links[
self._gerrit_link_name] = self._gerrit_link(change_id)
return GerritChange(
project=gerrit_project, upstream_ref=upstream_ref, id=change_id)
def _trigger_cq(self, change, dry_run):
"""Triggers CQ for the given change_id."""
# Decide which labels must be set.
# * Dry-run mode: just CQ+1.
# * Production mode: CQ+2 and CR+2 must be set to land the change.
if dry_run:
labels = {'Commit-Queue': 1}
else:
labels = {'Commit-Queue': 2, 'Code-Review': 2}
# Activate CQ.
# This call will return when Gerrit has set our desired labels on the change.
# It will also always set the CQ process in motion.
self.m.gerrit.set_review(
'submit to commit queue',
change.full_id,
labels=labels,
)
def _is_cq_complete(self, iteration, change, dry_run):
with self.m.context(infra_steps=True):
details = self.m.gerrit.change_details('check if done (%d)' % iteration,
change.full_id)
# If the CQ label is un-set, then that means either:
# * CQ failed (production mode), or
# * CQ finished (dry-run mode).
#
# 'recommended' and 'approved' are objects that appear for a label if
# somebody gave the label a positive vote (maximum vote (+2) for approved,
# non-maximum (+1) for 'recommended') and contains the information of one
# reviewer who gave this vote. There are 4 different states for a label in
# this sense: 'rejected', 'approved', 'disliked', and 'recommended'. For a
# given label, only one of these will be shown if the label has any votes
# in priority order 'rejected' > 'approved' > 'disliked' > 'recommended'.
# Unfortunately, this is the absolute simplest way to check this. Gerrit
# provides an 'all' field that contains every vote, but iterating over
# every vote, or operating under the assumption that there's at least one
# causes more error cases.
#
# Read more at:
# https://gerrit-review.googlesource.com/Documentation/rest-self.m-changes.html#get-change-detail
if details['status'] == 'ABANDONED':
return CQResult.ABANDONED
# In dry-run mode...
if dry_run:
# If CQ drops the CQ+1 label (i.e. 'recommended' state), then that means
# CQ finished trying. CQ will always remove the CQ+1 label when it's
# finished, regardless of success or failure.
if 'recommended' not in details['labels']['Commit-Queue']:
return CQResult.SUCCESS
# In production mode...
else:
# If it merged, we're done!
if details['status'] == 'MERGED':
return CQResult.SUCCESS
# If CQ drops the CQ+2 label at any point (i.e. 'approved' state), then
# that always means CQ has failed. CQ will always remove the CQ+2 label
# when it fails, and it will never remove it on success.
#
# Note: Because CQ won't unset the the CQ+2 label when it merges, there's
# no chance that we might see that the CL hasn't merged with the CQ+2
# label unset on a successful CQ.
if 'approved' not in details['labels']['Commit-Queue']:
return CQResult.FAILURE
# CQ is still running.
return None
def _wait_for_cq(self, change_id, dry_run):
"""Polls gerrit to see if CQ was successful.
Returns a CQResult representing the status of CQ.
"""
# Wait 30 seconds before polling for the first time to guard against Gerrit
# minor replication delays.
self.m.time.sleep(30)
with self.m.step.nest('check for completion'):
# TODO(mknyszek): Figure out a cleaner solution than polling.
num_iterations = int(self.poll_timeout_secs / self.poll_interval_secs)
for i in range(num_iterations + 1):
# Check the status of the CL.
status = self._is_cq_complete(i, change_id, dry_run)
if status:
return status
# If none of the terminal conditions above were reached (that is, there were
# no label changes from what we initially set, and the change has not
# merged or abandoned), then we should wait for |poll_interval_secs| before
# trying again. Don't sleep after the final check.
if i < num_iterations:
self.m.time.sleep(self.poll_interval_secs)
return CQResult.TIMEOUT
def attempt_roll(self,
gerrit_project,
repo_dir,
commit_message,
commit_untracked=False,
dry_run=False,
upstream_ref=UPSTREAM_REF):
"""Attempts to submit local edits via the CQ.
It additionally has two modes of operation, dry-run mode and production mode.
The precise steps it performs are as follows:
* Create a patch in Gerrit and grab Change ID
* Create a patch locally with Change ID
* Push local patch to Gerrit
* Production mode:
* Set labels Code-Review+2 and Commit-Queue+2 on Gerrit patch
* Wait for CQ to finish tryjobs and either merge the change or
remove the label Commit-Queue+2 (failed tryjobs)
* Abandon the change if the tryjobs failed
* Dry-run Mode:
* Set label Commit-Queue+1 on Gerrit patch
* Wait for CQ to finish tryjobs and remove label Commit-Queue+1
* Abandon the change to clean up
It assumes that repo_dir contains unstaged changes to only tracked files.
Args:
gerrit_project (str): The name of the project to roll to in Gerrit, which
is local to current Gerrit host as defined by api.gerrit.host(). For
example, "garnet" would be a valid gerrit_project for
fuchsia-review.googlesource.com.
repo_dir (Path): The path to the directory containing a local copy of the
git repo with changes that will be rolled.
commit_message (str): The commit message for the roll. Note that this method will
automatically append a Gerrit Change ID to the change. Also, it may be a
multiline string (embedded newlines are allowed).
commit_untracked (bool): Whether to commit untracked files as well.
dry_run (bool): Whether to execute this method in dry_run mode.
upstream_ref (str): The git ref to roll changes onto.
Returns:
bool: If there was a change to roll and CQ succeeded.
"""
# Check to see if there are actually any changes in repo_dir before
# continuing.
if not self._repo_has_uncommitted_files(repo_dir, commit_untracked):
self.m.step('no changes to roll', None)
return False
# Create the change both locally and remotely and push.
change = self._create_and_push_change(
gerrit_project=gerrit_project,
repo_dir=repo_dir,
commit_message=commit_message,
commit_untracked=commit_untracked,
upstream_ref=upstream_ref,
)
# Trigger CQ for the change ID.
self._trigger_cq(change, dry_run)
# Wait for CQ to complete.
result = self._wait_for_cq(change, dry_run)
# Interpret the result and finish.
if result == CQResult.SUCCESS:
if dry_run:
# Only abandon the roll on success if it was a dry-run.
self.m.gerrit.abandon('abandon roll: dry run complete', change.full_id)
return True
elif result == CQResult.FAILURE:
self._fail_roll(reason='CQ failed', change=change, abandon_cl=True)
elif result == CQResult.TIMEOUT:
self._fail_roll(
reason='auto-roller timeout', change=change, abandon_cl=True)
elif result == CQResult.ABANDONED:
self._fail_roll(reason='CL manually abandoned', change=change)
def _fail_roll(self, reason, change, abandon_cl=False):
if abandon_cl:
self.m.gerrit.abandon('abandon roll: ' + reason, change.full_id)
else:
self.m.step(reason, None)
cl_url = self._gerrit_link(change.id)
self.m.step.active_result.presentation.links[
self._gerrit_link_name] = cl_url
# TODO(olivernewman): Display the gerrit link in the build summary_markdown
# once luci_runner is deployed and Milo is able to display summary_markdown
# for passed builds.
raise self.m.step.StepFailure('Failed to roll changes: {reason}.\n\n'
'[{link_name}]({url})'.format(
reason=reason,
link_name=self._gerrit_link_name,
url=cl_url,
))
def _gerrit_link(self, change_id):
return self.m.url.join(self.m.gerrit.host, 'q', change_id)
def update_manifest_project(self, manifest, project_name, revision,
updated_deps):
"""Updates the revision for a project in a manifest.
Adds an entry to "updated_deps" if manifest is successfully updated;
otherwise, no new entries are added.
Args:
manifest (Path): Path to the Jiri manifest to update.
project_name (str): Name of the project in the Jiri manifest to update.
revision (str): SHA-1 hash representing the updated revision for
project_name in the manifest.
updated_deps (OrderedDict): A dictionary mapping project name to a formatted
log of the corresponding changes.
Returns:
The project's remote property.
"""
remote = self.m.jiri.read_manifest_element(
manifest=manifest,
element_type='project',
element_name=project_name,
).get('remote')
changes = self.m.jiri.edit_manifest(
manifest=manifest,
projects=[(project_name, revision)],
test_data={
'projects': [{
'old_revision': 'abc123',
'new_revision': 'def456'
}],
},
name='jiri edit %s' % project_name,
)
if not changes['projects']:
self.m.step.active_result.presentation.step_text = (
'manifest up-to-date, nothing to roll')
return remote
old_rev = changes['projects'][0]['old_revision']
new_rev = changes['projects'][0]['new_revision']
log = self.m.gitiles.log(
remote,
'%s..%s' % (old_rev, new_rev),
step_name='log %s' % project_name)
formatted_log = PROJECT_LOG_FORMAT.format(
project=project_name,
old=old_rev[:7],
new=new_rev[:7],
count=len(log),
commits='\n'.join([
'{commit} {subject}'.format(
commit=commit['id'][:7],
subject=commit['message'].splitlines()[0],
) for commit in log
]),
)
# Add dependency update entry
updated_deps[project_name] = formatted_log
return remote
def update_manifest_package(self, manifest, package_name, version,
updated_deps):
"""Updates the version for a package in a manifest.
Adds an entry to "updated_deps" if manifest is successfully updated;
otherwise, no new entries are added.
Args:
manifest (Path): Path to the Jiri manifest to update.
package_name (str): Name of the package in the Jiri manifest to update.
version (str): String representing the updated version for package_name
in the manifest.
updated_deps (OrderedDict): A dictionary mapping package name to a
formatted log of the corresponding changes.
"""
changes = self.m.jiri.edit_manifest(
manifest=manifest,
packages=[(package_name, version)],
test_data={
'packages': [{
'old_version': 'git_revision:abc123',
'new_version': 'git_revision:def456'
}],
},
name='jiri edit %s' % package_name,
)
if not changes['packages']:
self.m.step.active_result.presentation.step_text = (
'manifest up-to-date, nothing to roll')
return
formatted_log = PACKAGE_LOG_FORMAT.format(
package=package_name,
old=changes['packages'][0]['old_version'],
new=changes['packages'][0]['new_version'],
)
# Add dependency update entry
updated_deps[package_name] = formatted_log