blob: b9beba24c99abe2f8dbcce7eb47d06612b217212 [file] [log] [blame]
# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Module that handles the processing of patches to the source tree."""
import constants
import glob
import json
import logging
import os
import re
import shutil
import tempfile
from chromite.lib import cros_build_lib as cros_lib
# The prefix of the temporary directory created to store local patches.
_TRYBOT_TEMP_PREFIX = 'trybot_patch-'
# URL where the Commit Queue documentation is stored.
_PALADIN_DOCUMENTATION_URL = ('http://www.chromium.org/developers/'
'tree-sheriffs/sheriff-details-chromium-os/'
'commit-queue-overview')
class PatchException(Exception):
"""Exception thrown by GetGerritPatchInfo."""
pass
class ApplyPatchException(Exception):
"""Exception thrown if we fail to apply a patch."""
# Types used to denote what we failed to apply against.
TYPE_REBASE_TO_TOT = 1
TYPE_REBASE_TO_PATCH_INFLIGHT = 2
def __init__(self, patch, type=TYPE_REBASE_TO_TOT):
super(ApplyPatchException, self).__init__()
self.patch = patch
self.type = type
def __str__(self):
return 'Failed to apply patch ' + str(self.patch)
class MissingChangeIDException(Exception):
"""Raised if a patch is missing a Change-ID."""
pass
class Patch(object):
"""Abstract class representing a Git Patch."""
def __init__(self, project, tracking_branch):
"""Initialization of abstract Patch class.
Args:
project: The name of the project that the patch applies to.
tracking_branch: The remote branch of the project the patch applies to.
"""
self.project = project
self.tracking_branch = tracking_branch
def Apply(self, buildroot, trivial):
"""Applies the patch to specified buildroot. Implement in subclasses.
Args:
buildroot: The buildroot.
trivial: Only allow trivial merges when applying change.
Raises:
PatchException
"""
raise NotImplementedError('Applies the patch to specified buildroot.')
class GerritPatch(Patch):
"""Object that represents a Gerrit CL."""
_PUBLIC_URL = os.path.join(constants.GERRIT_HTTP_URL, 'gerrit/p')
_GIT_CHANGE_ID_RE = re.compile(r'^\s*Change-Id:\s*(\w+)\s*$', re.MULTILINE)
_PALADIN_DEPENDENCY_RE = re.compile(r'^\s*CQ-DEPEND=(.*)$', re.MULTILINE)
_PALADIN_BUG_RE = re.compile('(\w+)')
def __init__(self, patch_dict, internal):
"""Construct a GerritPatch object from Gerrit query results.
Gerrit query JSON fields are documented at:
http://gerrit-documentation.googlecode.com/svn/Documentation/2.2.1/json.html
Args:
patch_dict: A dictionary containing the parsed JSON gerrit query results.
internal: Whether the CL is an internal CL.
"""
Patch.__init__(self, patch_dict['project'], patch_dict['branch'])
self.internal = internal
# id - The CL's ChangeId
self.id = patch_dict['id']
# ref - The remote ref that contains the patch.
self.ref = patch_dict['currentPatchSet']['ref']
# revision - The CL's SHA1 hash.
self.revision = patch_dict['currentPatchSet']['revision']
self.patch_number = patch_dict['currentPatchSet']['number']
self.commit = patch_dict['currentPatchSet']['revision']
self.owner, _, _ = patch_dict['owner']['email'].partition('@')
self.gerrit_number = patch_dict['number']
self.url = patch_dict['url']
# status - Current state of this change. Can be one of
# ['NEW', 'SUBMITTED', 'MERGED', 'ABANDONED'].
self.status = patch_dict['status']
def IsAlreadyMerged(self):
"""Returns whether the patch has already been merged in Gerrit."""
return self.status == 'MERGED'
def _GetProjectUrl(self):
"""Returns the url to the gerrit project."""
if self.internal:
url_prefix = constants.GERRIT_INT_SSH_URL
else:
url_prefix = self._PUBLIC_URL
return os.path.join(url_prefix, self.project)
def _RebaseOnto(self, branch, upstream, project_dir, trivial):
"""Attempts to rebase FETCH_HEAD onto branch -- while not on a branch.
Raises:
cros_lib.RunCommandError: If the rebase operation returns an error code.
In this case, we still rebase --abort before returning.
"""
try:
git_rb = ['git', 'rebase']
if trivial: git_rb.extend(['--strategy', 'resolve', '-X', 'trivial'])
git_rb.extend(['--onto', branch, upstream, 'FETCH_HEAD'])
# Run the rebase command.
cros_lib.RunCommand(git_rb, cwd=project_dir)
except cros_lib.RunCommandError:
cros_lib.RunCommand(['git', 'rebase', '--abort'], cwd=project_dir,
error_ok=True)
raise
def _RebasePatch(self, buildroot, project_dir, trivial):
"""Rebase patch fetched from gerrit onto constants.PATCH_BRANCH.
When the function completes, the constants.PATCH_BRANCH branch will be
pointing to the rebased change.
Arguments:
buildroot: The buildroot.
project_dir: Directory of the project that is being patched.
trivial: Use trivial logic that only allows trivial merges. Note:
Requires Git >= 1.7.6 -- bug <. Bots have 1.7.6 installed.
Raises:
ApplyPatchException: If the patch failed to apply.
"""
url = self._GetProjectUrl()
upstream = _GetProjectManifestBranch(buildroot, self.project)
cros_lib.RunCommand(['git', 'fetch', url, self.ref], cwd=project_dir)
try:
self._RebaseOnto(constants.PATCH_BRANCH, upstream, project_dir, trivial)
cros_lib.RunCommand(['git', 'checkout', '-B', constants.PATCH_BRANCH],
cwd=project_dir)
except cros_lib.RunCommandError:
try:
# Failed to rebase against branch, try TOT.
self._RebaseOnto(upstream, upstream, project_dir, trivial)
except cros_lib.RunCommandError:
raise ApplyPatchException(
self, type=ApplyPatchException.TYPE_REBASE_TO_TOT)
else:
# We failed to apply to patch_branch but succeeded against TOT.
# We should pass a different type of exception in this case.
raise ApplyPatchException(
self, type=ApplyPatchException.TYPE_REBASE_TO_PATCH_INFLIGHT)
finally:
cros_lib.RunCommand(['git', 'checkout', constants.PATCH_BRANCH],
cwd=project_dir)
def Apply(self, buildroot, trivial=False):
"""Implementation of Patch.Apply().
Raises:
ApplyPatchException: If the patch failed to apply.
"""
project_dir = cros_lib.GetProjectDir(buildroot, self.project)
if not cros_lib.DoesLocalBranchExist(project_dir, constants.PATCH_BRANCH):
upstream = cros_lib.GetManifestDefaultBranch(buildroot)
cros_lib.RunCommand(['git', 'checkout', '-b', constants.PATCH_BRANCH,
'-t', 'm/' + upstream],
cwd=project_dir)
self._RebasePatch(buildroot, project_dir, trivial)
# --------------------- Gerrit Operations --------------------------------- #
@staticmethod
def _RunCommand(cmd, dryrun):
"""Runs the specified shell cmd if dryrun=False."""
if dryrun:
logging.info('Would have run: %s', ' '.join(cmd))
else:
cros_lib.RunCommand(cmd, error_ok=True)
@staticmethod
def ConstructErrorMessage(msg):
return msg + (' Please see %s for more information.' %
_PALADIN_DOCUMENTATION_URL)
def HandleCouldNotSubmit(self, helper, build_log, dryrun=False):
"""Handler that is called when the Commit Queue can't submit a change.
This should be rare, but if an admin overrides the commit queue and commits
a change that conflicts with this change, it'll apply, build/validate but
receive an error when submitting.
Args:
helper: Instance of gerrit_helper for the gerrit instance.
dryrun: If true, do not actually commit anything to Gerrit.
"""
msg = ('The Commit Queue failed to submit your change in %s . '
'This is most likely due to an owner of your repo overriding the '
'Commit Queue and committing a change that conflicts with yours. '
'Please rebase and re-upload your change to re-submit.' % build_log)
msg = self.ConstructErrorMessage(msg)
cmd = helper.GetGerritReviewCommand(
['-m', '"%s"' % msg, '%s,%s' % (self.gerrit_number, self.patch_number)])
GerritPatch._RunCommand(cmd, dryrun)
self.RemoveCommitReady(helper, dryrun)
def HandleCouldNotVerify(self, helper, build_log, dryrun=False):
"""Handler for when the Commit Queue fails to validate a change.
This handler notifies set Verified-1 to the review forcing the developer
to re-upload a change that works. There are many reasons why this might be
called e.g. build or testing exception.
Args:
helper: Instance of gerrit_helper for the gerrit instance.
build_log: URL to the build log where verification results could be
found.
dryrun: If true, do not actually commit anything to Gerrit.
"""
msg = ('The Commit Queue failed to verify your change in %s . '
'If you believe this happened in error, just re-mark your commit as '
'ready. Your change will then get automatically retried.' %
build_log)
msg = self.ConstructErrorMessage(msg)
cmd = helper.GetGerritReviewCommand(
['-m', '"%s"' % msg, '%s,%s' % (self.gerrit_number, self.patch_number)])
GerritPatch._RunCommand(cmd, dryrun)
self.RemoveCommitReady(helper, dryrun)
def RemoveCommitReady(self, helper, dryrun=False):
"""Remove any commit ready bits associated with CL."""
query = ['-c',
'"delete from patch_set_approvals where change_id=%s'
' AND category_id=\'COMR\';"' % self.gerrit_number
]
cmd = helper.GetGerritSqlCommand(query)
GerritPatch._RunCommand(cmd, dryrun)
def HandleCouldNotApply(self, helper, build_log, dryrun=False):
"""Handler for when the Commit Queue fails to apply a change.
This handler notifies set CodeReview-2 to the review forcing the developer
to re-upload a rebased change.
Args:
helper: Instance of gerrit_helper for the gerrit instance.
dryrun: If true, do not actually commit anything to Gerrit.
"""
msg = ('The Commit Queue failed to apply your change cleanly in %s . '
'Please re-sync, rebase, and re-upload your change.' % build_log)
msg = self.ConstructErrorMessage(msg)
cmd = helper.GetGerritReviewCommand(
['-m', '"%s"' % msg, '%s,%s' % (self.gerrit_number, self.patch_number)])
GerritPatch._RunCommand(cmd, dryrun)
self.RemoveCommitReady(helper, dryrun)
def CommitMessage(self, buildroot):
"""Returns the commit message for the patch as a string."""
url = self._GetProjectUrl()
project_dir = cros_lib.GetProjectDir(buildroot, self.project)
cros_lib.RunCommand(['git', 'fetch', url, self.ref], cwd=project_dir)
return_obj = cros_lib.RunCommand(['git', 'show', '-s', 'FETCH_HEAD'],
cwd=project_dir, redirect_stdout=True)
return return_obj.output
def GerritDependencies(self, buildroot):
"""Returns an ordered list of dependencies from Gerrit.
The list of changes are in order from FETCH_HEAD back to m/master.
Arguments:
buildroot: The buildroot.
Returns:
An ordered list of Gerrit revisions that this patch depends on.
Raises:
MissingChangeIDException: If a dependent change is missing its ChangeID.
"""
dependencies = []
url = self._GetProjectUrl()
project_dir = cros_lib.GetProjectDir(buildroot, self.project)
cros_lib.RunCommand(['git', 'fetch', url, self.ref], cwd=project_dir)
return_obj = cros_lib.RunCommand(
['git', 'log', '-z', '%s..FETCH_HEAD^' %
_GetProjectManifestBranch(buildroot, self.project)],
cwd=project_dir, redirect_stdout=True)
for patch_output in return_obj.output.split('\0'):
if not patch_output: continue
change_id_match = self._GIT_CHANGE_ID_RE.search(patch_output)
if change_id_match:
dependencies.append(change_id_match.group(1))
else:
raise MissingChangeIDException('Missing Change-Id in %s' % patch_output)
logging.debug('Found %s Gerrit dependencies for change %s', dependencies,
self)
return dependencies
def PaladinDependencies(self, buildroot):
"""Returns an ordered list of dependencies based on the Commit Message.
Parses the Commit message for this change looking for lines that follow
the format:
CQ-DEPEND:change_num+ e.g.
A commit which depends on a couple others.
BUG=blah
TEST=blah
CQ-DEPEND=10001,10002
"""
dependencies = []
commit_message = self.CommitMessage(buildroot)
matches = self._PALADIN_DEPENDENCY_RE.findall(commit_message)
for match in matches:
dependencies.extend(self._PALADIN_BUG_RE.findall(match))
logging.debug('Found %s Paladin dependencies for change %s', dependencies,
self)
return dependencies
def Submit(self, helper, dryrun=False):
"""Submits patch using Gerrit Review.
Args:
helper: Instance of gerrit_helper for the gerrit instance.
dryrun: If true, do not actually commit anything to Gerrit.
"""
cmd = helper.GetGerritReviewCommand(['--submit', '%s,%s' % (
self.gerrit_number, self.patch_number)])
GerritPatch._RunCommand(cmd, dryrun)
def __str__(self):
"""Returns custom string to identify this patch."""
return '%s:%s' % (self.owner, self.gerrit_number)
# Define methods to use patches in sets. We uniquely identify patches
# by Gerrit change numbers.
def __hash__(self):
return hash(self.id)
def __eq__(self, other):
return self.id == other.id
def RemovePatchRoot(patch_root):
"""Removes the temporary directory storing patches."""
assert os.path.basename(patch_root).startswith(_TRYBOT_TEMP_PREFIX)
shutil.rmtree(patch_root)
class LocalPatch(Patch):
"""Object that represents a set of local commits that will be patched."""
def __init__(self, project, tracking_branch, patch_dir, local_branch):
"""Construct a LocalPatch object.
Args:
project: Same as Patch constructor arg.
tracking_branch: Same as Patch constructor arg.
patch_dir: The directory where the .patch files are stored.
local_branch: The local branch of the project that the patch came from.
"""
Patch.__init__(self, project, tracking_branch)
self.patch_dir = patch_dir
self.local_branch = local_branch
def _GetFileList(self):
"""Return a list of .patch files in sorted order."""
file_list = glob.glob(os.path.join(self.patch_dir, '*'))
file_list.sort()
return file_list
def Apply(self, buildroot, trivial=False):
"""Implementation of Patch.Apply(). Does not accept trivial option.
Raises:
PatchException if the patch is for the wrong tracking branch.
"""
assert not trivial, 'Local apply not compatible with trivial set'
manifest_branch = _GetProjectManifestBranch(buildroot, self.project)
if self.tracking_branch != manifest_branch:
raise PatchException('branch %s for project %s is not tracking %s'
% (self.local_branch, self.project,
manifest_branch))
project_dir = cros_lib.GetProjectDir(buildroot, self.project)
try:
cros_lib.RunCommand(['repo', 'start', constants.PATCH_BRANCH, '.'],
cwd=project_dir)
cros_lib.RunCommand(['git', 'am', '--3way'] + self._GetFileList(),
cwd=project_dir)
except cros_lib.RunCommandError:
raise ApplyPatchException(self)
def __str__(self):
"""Returns custom string to identify this patch."""
return '%s:%s' % (self.project, self.local_branch)
def GetGerritPatchInfo(patches):
"""Query Gerrit server for patch information.
Args:
patches: a list of patch ID's to query. Internal patches start with a '*'.
Returns:
A list of GerritPatch objects describing each patch.
Raises:
PatchException if a patch can't be found.
"""
parsed_patches = []
for patch in patches:
if patch.startswith('*'):
# Internal CL's have a '*' in front
internal = True
server, port = constants.GERRIT_INT_HOST, constants.GERRIT_INT_PORT
patch = patch[1:]
else:
internal = False
server, port = constants.GERRIT_HOST, constants.GERRIT_PORT
cmd = ['ssh', '-p', port, server, 'gerrit', 'query', '--current-patch-set',
'--format=JSON', patch]
result = cros_lib.RunCommand(cmd, redirect_stdout=True)
result_dict = json.loads(result.output.splitlines()[0])
if 'id' in result_dict:
parsed_patches.append(GerritPatch(result_dict, internal))
else:
raise PatchException('Change-ID %s not found on server %s.'
% (patch, server))
return parsed_patches
def _GetRemoteTrackingBranch(project_dir, branch):
"""Get the remote tracking branch of a local branch.
Raises:
cros_lib.NoTrackingBranchException if branch does not track anything.
"""
(remote, ref) = cros_lib.GetTrackingBranch(branch, project_dir)
return cros_lib.GetShortBranchName(remote, ref)
def _GetProjectManifestBranch(buildroot, project):
"""Get the branch specified in the manifest for the project."""
(remote, ref) = cros_lib.GetProjectManifestBranch(buildroot,
project)
return cros_lib.GetShortBranchName(remote, ref)
def PrepareLocalPatches(patches, manifest_branch):
"""Finish validation of parameters, and save patches to a temp folder.
Args:
patches: A list of user-specified patches, in project[:branch] form.
manifest_branch: The manifest branch of the buildroot.
Raises:
PatchException if:
1. The project branch isn't specified and the project isn't on a branch.
2. The project branch doesn't track a remote branch.
"""
patch_info = []
patch_root = tempfile.mkdtemp(prefix=_TRYBOT_TEMP_PREFIX)
for patch_id in range(0, len(patches)):
project, branch = patches[patch_id].split(':')
project_dir = cros_lib.GetProjectDir('.', project)
patch_dir = os.path.join(patch_root, str(patch_id))
cmd = ['git', 'format-patch', '%s..%s' % ('m/' + manifest_branch, branch),
'-o', patch_dir]
cros_lib.RunCommand(cmd, redirect_stdout=True, cwd=project_dir)
if not os.listdir(patch_dir):
raise PatchException('No changes found in %s:%s' % (project, branch))
# Store remote tracking branch for verification during patch stage.
try:
tracking_branch = _GetRemoteTrackingBranch(project_dir, branch)
except cros_lib.NoTrackingBranchException:
raise PatchException('%s:%s needs to track a remote branch!'
% (project, branch))
patch_info.append(LocalPatch(project, tracking_branch, patch_dir, branch))
return patch_info