blob: 46f535615c36b023d11957ad1330b06cda278bd8 [file] [log] [blame]
# -*- coding: utf-8 -*-
# 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 containing helper class and methods for interacting with Gerrit."""
from __future__ import print_function
import operator
from chromite.lib import config_lib
from chromite.lib import constants
from chromite.lib import cros_build_lib
from chromite.lib import cros_logging as logging
from chromite.lib import git
from chromite.lib import gob_util
from chromite.lib import parallel
from chromite.lib import patch as cros_patch
class GerritException(Exception):
"""Base exception, thrown for gerrit failures"""
class QueryHasNoResults(GerritException):
"""Exception thrown when a query returns no results."""
class QueryNotSpecific(GerritException):
"""Thrown when a query needs to identify one CL, but matched multiple."""
class GerritHelper(object):
"""Helper class to manage interaction with the gerrit-on-borg service."""
# Maximum number of results to return per query.
# Number of processes to run in parallel when fetching from Gerrit. The
# Gerrit team recommended keeping this small to avoid putting too much
# load on the server.
# Fields that appear in gerrit change query results.
MORE_CHANGES = '_more_changes'
def __init__(self, host, remote, print_cmd=True):
host: Hostname (without protocol prefix) of the gerrit server.
remote: The symbolic name of a known remote git host,
taken from cbuildbot.contants.
print_cmd: Determines whether all RunCommand invocations will be echoed.
Set to False for quiet operation.
""" = host
self.remote = remote
self.print_cmd = bool(print_cmd)
self._version = None
def FromRemote(cls, remote, **kwargs):
site_params = config_lib.GetSiteParams()
if remote == site_params.INTERNAL_REMOTE:
host = site_params.INTERNAL_GERRIT_HOST
elif remote == site_params.EXTERNAL_REMOTE:
host = site_params.EXTERNAL_GERRIT_HOST
raise ValueError('Remote %s not supported.' % remote)
return cls(host, remote, **kwargs)
def FromGob(cls, gob, **kwargs):
"""Return a helper for a GoB instance."""
site_params = config_lib.GetSiteParams()
host = constants.GOB_HOST % ('%s-review' % gob)
# TODO(phobbs) this will be wrong when "gob" isn't in GOB_REMOTES.
# We should get rid of remotes altogether and just use the host.
return cls(host, site_params.GOB_REMOTES.get(gob, gob), **kwargs)
def SetAssignee(self, change, assignee, dryrun=False):
"""Set assignee on a gerrit change.
change: ChangeId or change number for a gerrit review.
assignee: email address of reviewer to add.
dryrun: If True, only print what would have been done.
if dryrun:'Would have added %s to "%s"', assignee, change)
gob_util.AddAssignee(, change, assignee)
def SetPrivate(self, change, private):
"""Sets the private bit on the given CL.
change: CL number.
private: bool to indicate what value to set for the private bit.
if private:
gob_util.MarkPrivate(, change)
gob_util.MarkNotPrivate(, change)
def SetReviewers(self, change, add=(), remove=(), dryrun=False):
"""Modify the list of reviewers on a gerrit change.
change: ChangeId or change number for a gerrit review.
add: Sequence of email addresses of reviewers to add.
remove: Sequence of email addresses of reviewers to remove.
dryrun: If True, only print what would have been done.
if add:
if dryrun:'Would have added %s to "%s"', add, change)
gob_util.AddReviewers(, change, add)
if remove:
if dryrun:'Would have removed %s to "%s"', remove, change)
gob_util.RemoveReviewers(, change, remove)
def GetChangeDetail(self, change_num, verbose=False):
"""Return detailed information about a gerrit change.
change_num: A gerrit change number.
verbose: (optional) Whether to print more properties of the change
if verbose:
return gob_util.GetChangeDetail(, change_num, o_params=o_params)
def GrabPatchFromGerrit(self, project, change, commit, must_match=True):
"""Return a cros_patch.GerritPatch representing a gerrit change.
project: The name of the gerrit project for the change.
change: A ChangeId or gerrit number for the change.
commit: The git commit hash for a patch associated with the change.
must_match: Raise an exception if the change is not found.
query = {'project': project, 'commit': commit, 'must_match': must_match}
return self.QuerySingleRecord(change, **query)
def IsChangeCommitted(self, change, must_match=False):
"""Check whether a gerrit change has been merged.
change: A gerrit change number.
must_match: Raise an exception if the change is not found. If this is
False, then a missing change will return None.
change = gob_util.GetChange(, change)
if not change:
if must_match:
raise QueryHasNoResults('Could not query for change %s' % change)
return change.get('status') == 'MERGED'
def GetLatestSHA1ForBranch(self, project, branch):
"""Return the git hash at the tip of a branch."""
url = '%s://%s/%s' % (gob_util.GIT_PROTOCOL,, project)
cmd = ['ls-remote', url, 'refs/heads/%s' % branch]
result = git.RunGit('.', cmd, print_cmd=self.print_cmd)
if result:
return result.output.split()[0]
except cros_build_lib.RunCommandError:
logging.error('Command "%s" failed.', cros_build_lib.CmdToStr(cmd),
def QuerySingleRecord(self, change=None, **kwargs):
"""Free-form query of a gerrit change that expects a single result.
change: A gerrit change number.
dryrun: Don't query the gerrit server; just return None.
must_match: Raise an exception if the query comes back empty. If this
is False, an unsatisfied query will return None.
Refer to Query() docstring for remaining arguments.
If kwargs['raw'] == True, return a python dict representing the
change; otherwise, return a cros_patch.GerritPatch object.
query_kwds = kwargs
dryrun = query_kwds.get('dryrun')
must_match = query_kwds.pop('must_match', True)
results = self.Query(change, **query_kwds)
if dryrun:
return None
elif not results:
if must_match:
raise QueryHasNoResults('Query %s had no results' % (change,))
return None
elif len(results) != 1:
raise QueryNotSpecific('Query %s returned too many results: %s'
% (change, results))
return results[0]
def Query(self, change=None, sort=None, current_patch=True, options=(),
dryrun=False, raw=False, start=None, bypass_cache=True,
verbose=False, **kwargs):
"""Free-form query for gerrit changes.
change: ChangeId, git commit hash, or gerrit number for a change.
sort: A functor to extract a sort key from a cros_patch.GerritChange
object, for sorting results.. If this is None, results will not be
current_patch: If True, ask the gerrit server for extra information about
the latest uploaded patch.
options: Deprecated.
dryrun: If True, don't query the gerrit server; return an empty list.
raw: If True, return a list of python dict's representing the query
results. Otherwise, return a list of cros_patch.GerritPatch.
start: Offset in the result set to start at.
bypass_cache: Query each change to make sure data is up to date.
verbose: Whether to get all revisions and details about a change.
kwargs: A dict of query parameters, as described here:
A list of python dicts or cros_patch.GerritChange.
query_kwds = kwargs
if options:
raise GerritException('"options" argument unsupported on gerrit-on-borg.')
url_prefix = gob_util.GetGerritFetchUrl(
# All possible params are documented at
# pylint: disable=C0301
if current_patch:
if change and cros_patch.ParseGerritNumber(change) and not query_kwds:
if dryrun:'Would have run gob_util.GetChangeDetail(%s, %s)',, change)
return []
change = self.GetChangeDetail(change, verbose=verbose)
if change is None:
return []
patch_dict = cros_patch.GerritPatch.ConvertQueryResults(change,
if raw:
return [patch_dict]
return [cros_patch.GerritPatch(patch_dict, self.remote, url_prefix)]
# TODO: We should allow querying using a cros_patch.PatchQuery
# object directly.
if change and cros_patch.ParseSHA1(change):
# Use commit:sha1 for accurate query results (
kwargs['commit'] = change
change = None
elif change and cros_patch.ParseChangeID(change):
# Use change:change-id for accurate query results (
kwargs['change'] = change
change = None
elif change and cros_patch.ParseFullChangeID(change):
change = cros_patch.ParseFullChangeID(change)
kwargs['change'] = change.change_id
kwargs['project'] = change.project
kwargs['branch'] = change.branch
change = None
if change and query_kwds.get('change'):
raise GerritException('Bad query params: provided a change-id-like query,'
' and a "change" search parameter')
if dryrun:'Would have run gob_util.QueryChanges(%s, %s, '
'first_param=%s, limit=%d)',, repr(query_kwds),
return []
start = 0
moar = gob_util.QueryChanges(, query_kwds, first_param=change, start=start,
limit=self._GERRIT_MAX_QUERY_RETURN, o_params=o_params)
result = list(moar)
while moar and self.MORE_CHANGES in moar[-1]:
start += len(moar)
moar = gob_util.QueryChanges(, query_kwds, first_param=change, start=start,
limit=self._GERRIT_MAX_QUERY_RETURN, o_params=o_params)
# NOTE: Query results are served from the gerrit cache, which may be stale.
# To make sure the patch information is accurate, re-request each query
# result directly, circumventing the cache. For reference:
if bypass_cache:
result = self.GetMultipleChangeDetail(
[x['_number'] for x in result], verbose=verbose)
result = [cros_patch.GerritPatch.ConvertQueryResults(
x, for x in result]
if sort:
result = sorted(result, key=operator.itemgetter(sort))
if raw:
return result
return [cros_patch.GerritPatch(x, self.remote, url_prefix) for x in result]
def GetMultipleChangeDetail(self, changes, verbose=False):
"""Query the gerrit server for multiple changes using GetChangeDetail.
changes: A sequence of gerrit change numbers.
verbose: (optional) Whether to return more properties of the change
A list of the raw output of GetChangeDetail.
inputs = [[change] for change in changes]
return parallel.RunTasksInProcessPool(
lambda c: self.GetChangeDetail(c, verbose=verbose),
inputs, processes=self._NUM_PROCESSES)
def QueryMultipleCurrentPatchset(self, changes):
"""Query the gerrit server for multiple changes.
changes: A sequence of gerrit change numbers.
A list of cros_patch.GerritPatch.
if not changes:
url_prefix = gob_util.GetGerritFetchUrl(
results = self.GetMultipleChangeDetail(changes)
for change, change_detail in zip(changes, results):
if not change_detail:
raise GerritException('Change %s not found on server %s.'
% (change,
patch_dict = cros_patch.GerritPatch.ConvertQueryResults(
yield change, cros_patch.GerritPatch(patch_dict, self.remote, url_prefix)
def _to_changenum(change):
"""Unequivocally return a gerrit change number.
The argument may either be an number, which will be returned unchanged;
or an instance of GerritPatch, in which case its gerrit number will be
# TODO(davidjames): Deprecate the ability to pass in strings to these
# functions -- API users should just pass in a GerritPatch instead or use
# the gob_util APIs directly.
if isinstance(change, cros_patch.GerritPatch):
return change.gerrit_number
return change
def SetReview(self, change, msg=None, labels=None,
dryrun=False, notify='ALL'):
"""Update the review labels on a gerrit change.
change: A gerrit change number.
msg: A text comment to post to the review.
labels: A dict of label/value to set on the review.
dryrun: If True, don't actually update the review.
notify: A string, parameter controlling gerrit's email generation.
if not msg and not labels:
if dryrun:
if msg:'Would have added message "%s" to change "%s".', msg,
if labels:
for key, val in labels.iteritems():'Would have set label "%s" to "%s" for change "%s".',
key, val, change)
gob_util.SetReview(, self._to_changenum(change),
msg=msg, labels=labels, notify=notify)
def SetTopic(self, change, topic, dryrun=False):
"""Update the topic on a gerrit change.
change: A gerrit change number.
topic: The topic to set the review to.
dryrun: If True, don't actually set the topic.
if dryrun:'Would have set topic "%s" for change "%s".', topic, change)
gob_util.SetTopic(, self._to_changenum(change), topic=topic)
def SetHashtags(self, change, add, remove, dryrun=False):
"""Add/Remove hashtags for a gerrit change.
change: A gerrit change number.
add: a list of hashtags to add.
remove: a list of hashtags to remove.
dryrun: If True, don't actually set the hashtag.
if dryrun:'Would add %r and remove %r for change %s',
add, remove, change)
gob_util.SetHashtags(, self._to_changenum(change),
add=add, remove=remove)
def RemoveReady(self, change, dryrun=False):
"""Set the 'Commit-Queue' label on a |change| to '0'."""
if dryrun:'Would have reset Commit-Queue label for %s', change)
gob_util.ResetReviewLabels(, self._to_changenum(change),
label='Commit-Queue', notify='OWNER')
def SubmitChange(self, change, dryrun=False):
"""Land (merge) a gerrit change using the JSON API."""
if dryrun:'Would have submitted change %s', change)
if isinstance(change, cros_patch.GerritPatch):
rev = change.sha1
rev = None
gob_util.SubmitChange(, self._to_changenum(change), revision=rev)
def AbandonChange(self, change, dryrun=False):
"""Mark a gerrit change as 'Abandoned'."""
if dryrun:'Would have abandoned change %s', change)
gob_util.AbandonChange(, self._to_changenum(change))
def RestoreChange(self, change, dryrun=False):
"""Re-activate a previously abandoned gerrit change."""
if dryrun:'Would have restored change %s', change)
gob_util.RestoreChange(, self._to_changenum(change))
def DeleteDraft(self, change, dryrun=False):
"""Delete a gerrit change iff all its revisions are drafts."""
if dryrun:'Would have deleted draft change %s', change)
gob_util.DeleteDraft(, self._to_changenum(change))
def GetAccount(self):
"""Get information about the user account."""
return gob_util.GetAccount(
def GetGerritPatchInfo(patches):
"""Query Gerrit server for patch information using string queries.
patches: A list of patch IDs to query. Internal patches start with a '*'.
A list of GerritPatch objects describing each patch. Only the first
instance of a requested patch is returned.
PatchException if a patch can't be found.
ValueError if a query string cannot be converted to a PatchQuery object.
return GetGerritPatchInfoWithPatchQueries(
[cros_patch.ParsePatchDep(p) for p in patches])
def GetGerritPatchInfoWithPatchQueries(patches):
"""Query Gerrit server for patch information using PatchQuery objects.
patches: A list of PatchQuery objects to query.
A list of GerritPatch objects describing each patch. Only the first
instance of a requested patch is returned.
PatchException if a patch can't be found.
site_params = config_lib.GetSiteParams()
seen = set()
results = []
order = {k.ToGerritQueryText(): idx for (idx, k) in enumerate(patches)}
for remote in site_params.CHANGE_PREFIX.keys():
helper = GetGerritHelper(remote)
raw_ids = [x.ToGerritQueryText() for x in patches if x.remote == remote]
for k, change in helper.QueryMultipleCurrentPatchset(raw_ids):
# return a unique list, while maintaining the ordering of the first
# seen instance of each patch. Do this to ensure whatever ordering
# the user is trying to enforce, we honor; lest it break on
# cherry-picking.
if not in seen:
results.append((order[k], change))
return [change for _idx, change in sorted(results)]
def GetGerritHelper(remote=None, gob=None, **kwargs):
"""Return a GerritHelper instance for interacting with the given remote."""
if gob:
return GerritHelper.FromGob(gob, **kwargs)
return GerritHelper.FromRemote(remote, **kwargs)
def GetGerritHelperForChange(change):
"""Return a usable GerritHelper instance for this change.
If you need a GerritHelper for a specific change, get it via this
return GetGerritHelper(change.remote)
def GetCrosInternal(**kwargs):
"""Convenience method for accessing private ChromeOS gerrit."""
site_params = config_lib.GetSiteParams()
return GetGerritHelper(site_params.INTERNAL_REMOTE, **kwargs)
def GetCrosExternal(**kwargs):
"""Convenience method for accessing public ChromiumOS gerrit."""
site_params = config_lib.GetSiteParams()
return GetGerritHelper(site_params.EXTERNAL_REMOTE, **kwargs)
def GetChangeRef(change_number, patchset=None):
"""Given a change number, return the refs/changes/* space for it.
change_number: The gerrit change number you want a refspec for.
patchset: If given it must either be an integer or '*'. When given,
the returned refspec is for that exact patchset. If '*' is given, it's
used for pulling down all patchsets for that change.
A git refspec.
change_number = int(change_number)
s = 'refs/changes/%02i/%i' % (change_number % 100, change_number)
if patchset is not None:
s += '/%s' % ('*' if patchset == '*' else int(patchset))
return s