1 # Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
5 """Module containing helper class and methods for interacting with Gerrit."""
7 from __future__ import print_function
11 from chromite.cbuildbot import constants
12 from chromite.lib import cros_build_lib
13 from chromite.lib import git
14 from chromite.lib import gob_util
15 from chromite.lib import patch as cros_patch
17 gob_util.LOGGER = cros_build_lib.logger
20 class GerritException(Exception):
21 """Base exception, thrown for gerrit failures"""
24 class QueryHasNoResults(GerritException):
25 """Exception thrown when a query returns no results."""
28 class QueryNotSpecific(GerritException):
29 """Thrown when a query needs to identify one CL, but matched multiple."""
32 class FailedToReachGerrit(GerritException):
33 """Exception thrown if we failed to contact the Gerrit server."""
36 class GerritHelper(object):
37 """Helper class to manage interaction with the gerrit-on-borg service."""
39 # Maximum number of results to return per query.
40 _GERRIT_MAX_QUERY_RETURN = 500
42 # Fields that appear in gerrit change query results.
43 MORE_CHANGES = '_more_changes'
45 def __init__(self, host, remote, print_cmd=True):
49 host: Hostname (without protocol prefix) of the gerrit server.
50 remote: The symbolic name of a known remote git host,
51 taken from cbuildbot.contants.
52 print_cmd: Determines whether all RunCommand invocations will be echoed.
53 Set to False for quiet operation.
57 self.print_cmd = bool(print_cmd)
61 def FromRemote(cls, remote, **kwargs):
62 if remote == constants.INTERNAL_REMOTE:
63 host = constants.INTERNAL_GERRIT_HOST
64 elif remote == constants.EXTERNAL_REMOTE:
65 host = constants.EXTERNAL_GERRIT_HOST
67 raise ValueError('Remote %s not supported.' % remote)
68 return cls(host, remote, **kwargs)
71 def FromGob(cls, gob, **kwargs):
72 """Return a helper for a GoB instance."""
73 host = constants.GOB_HOST % ('%s-review' % gob)
74 return cls(host, gob, **kwargs)
76 def SetReviewers(self, change, add=(), remove=(), dryrun=False):
77 """Modify the list of reviewers on a gerrit change.
80 change: ChangeId or change number for a gerrit review.
81 add: Sequence of email addresses of reviewers to add.
82 remove: Sequence of email addresses of reviewers to remove.
83 dryrun: If True, only print what would have been done.
87 cros_build_lib.Info('Would have added %s to "%s"', add, change)
89 gob_util.AddReviewers(self.host, change, add)
92 cros_build_lib.Info('Would have removed %s to "%s"', remove, change)
94 gob_util.RemoveReviewers(self.host, change, remove)
96 def GetChangeDetail(self, change_num):
97 """Return detailed information about a gerrit change.
100 change_num: A gerrit change number.
102 return gob_util.GetChangeDetail(
103 self.host, change_num, o_params=('CURRENT_REVISION', 'CURRENT_COMMIT'))
105 def GrabPatchFromGerrit(self, project, change, commit, must_match=True):
106 """Return a cros_patch.GerritPatch representing a gerrit change.
109 project: The name of the gerrit project for the change.
110 change: A ChangeId or gerrit number for the change.
111 commit: The git commit hash for a patch associated with the change.
112 must_match: Raise an exception if the change is not found.
114 query = { 'project': project, 'commit': commit, 'must_match': must_match }
115 return self.QuerySingleRecord(change, **query)
117 def IsChangeCommitted(self, change, must_match=False):
118 """Check whether a gerrit change has been merged.
121 change: A gerrit change number.
122 must_match: Raise an exception if the change is not found. If this is
123 False, then a missing change will return None.
125 change = gob_util.GetChange(self.host, change)
128 raise QueryHasNoResults('Could not query for change %s' % change)
130 return change.get('status') == 'MERGED'
132 def GetLatestSHA1ForBranch(self, project, branch):
133 """Return the git hash at the tip of a branch."""
134 url = '%s://%s/%s' % (gob_util.GIT_PROTOCOL, self.host, project)
135 cmd = ['ls-remote', url, 'refs/heads/%s' % branch]
137 result = git.RunGit('.', cmd, print_cmd=self.print_cmd)
139 return result.output.split()[0]
140 except cros_build_lib.RunCommandError:
141 cros_build_lib.Error('Command "%s" failed.', cros_build_lib.CmdToStr(cmd),
144 def QuerySingleRecord(self, change=None, **kwargs):
145 """Free-form query of a gerrit change that expects a single result.
148 change: A gerrit change number.
150 dryrun: Don't query the gerrit server; just return None.
151 must_match: Raise an exception if the query comes back empty. If this
152 is False, an unsatisfied query will return None.
153 Refer to Query() docstring for remaining arguments.
156 If kwargs['raw'] == True, return a python dict representing the
157 change; otherwise, return a cros_patch.GerritPatch object.
160 dryrun = query_kwds.get('dryrun')
161 must_match = query_kwds.pop('must_match', True)
162 results = self.Query(change, **query_kwds)
167 raise QueryHasNoResults('Query %s had no results' % (change,))
169 elif len(results) != 1:
170 raise QueryNotSpecific('Query %s returned too many results: %s'
174 def Query(self, change=None, sort=None, current_patch=True, options=(),
175 dryrun=False, raw=False, start=None, bypass_cache=True, **kwargs):
176 """Free-form query for gerrit changes.
179 change: ChangeId, git commit hash, or gerrit number for a change.
180 sort: A functor to extract a sort key from a cros_patch.GerritChange
181 object, for sorting results.. If this is None, results will not be
183 current_patch: If True, ask the gerrit server for extra information about
184 the latest uploaded patch.
186 dryrun: If True, don't query the gerrit server; return an empty list.
187 raw: If True, return a list of python dict's representing the query
188 results. Otherwise, return a list of cros_patch.GerritPatch.
189 start: Offset in the result set to start at.
190 bypass_cache: Query each change to make sure data is up to date.
191 kwargs: A dict of query parameters, as described here:
192 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
195 A list of python dicts or cros_patch.GerritChange.
199 raise GerritException('"options" argument unsupported on gerrit-on-borg.')
200 url_prefix = gob_util.GetGerritFetchUrl(self.host)
201 # All possible params are documented at
202 # pylint: disable=C0301
203 # https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
204 o_params = ['DETAILED_ACCOUNTS', 'ALL_REVISIONS', 'DETAILED_LABELS']
206 o_params.extend(['CURRENT_COMMIT', 'CURRENT_REVISION'])
208 if change and cros_patch.ParseGerritNumber(change) and not query_kwds:
210 cros_build_lib.Info('Would have run gob_util.GetChangeDetail(%s, %s)',
213 change = self.GetChangeDetail(change)
216 patch_dict = cros_patch.GerritPatch.ConvertQueryResults(change, self.host)
219 return [cros_patch.GerritPatch(patch_dict, self.remote, url_prefix)]
221 # TODO: We should allow querying using a cros_patch.PatchQuery
223 if change and cros_patch.ParseSHA1(change):
224 # Use commit:sha1 for accurate query results (crbug.com/358381).
225 kwargs['commit'] = change
227 elif change and cros_patch.ParseChangeID(change):
228 # Use change:change-id for accurate query results (crbug.com/357876).
229 kwargs['change'] = change
231 elif change and cros_patch.ParseFullChangeID(change):
232 project, branch, change_id = cros_patch.ParseFullChangeID(change)
233 kwargs['change'] = change_id
234 kwargs['project'] = project
235 kwargs['branch'] = branch
238 if change and query_kwds.get('change'):
239 raise GerritException('Bad query params: provided a change-id-like query,'
240 ' and a "change" search parameter')
244 'Would have run gob_util.QueryChanges(%s, %s, first_param=%s, '
245 'limit=%d)', self.host, repr(query_kwds), change,
246 self._GERRIT_MAX_QUERY_RETURN)
250 moar = gob_util.QueryChanges(
251 self.host, query_kwds, first_param=change, start=start,
252 limit=self._GERRIT_MAX_QUERY_RETURN, o_params=o_params)
254 while moar and self.MORE_CHANGES in moar[-1]:
256 moar = gob_util.QueryChanges(
257 self.host, query_kwds, first_param=change, start=start,
258 limit=self._GERRIT_MAX_QUERY_RETURN, o_params=o_params)
261 # NOTE: Query results are served from the gerrit cache, which may be stale.
262 # To make sure the patch information is accurate, re-request each query
263 # result directly, circumventing the cache. For reference:
264 # https://code.google.com/p/chromium/issues/detail?id=302072
266 result = [self.GetChangeDetail(x['_number']) for x in result]
268 result = [cros_patch.GerritPatch.ConvertQueryResults(
269 x, self.host) for x in result]
271 result = sorted(result, key=operator.itemgetter(sort))
274 return [cros_patch.GerritPatch(x, self.remote, url_prefix) for x in result]
276 def QueryMultipleCurrentPatchset(self, changes):
277 """Query the gerrit server for multiple changes.
280 changes: A sequence of gerrit change numbers.
283 A list of cros_patch.GerritPatch.
287 url_prefix = gob_util.GetGerritFetchUrl(self.host)
288 for change in changes:
289 change_detail = self.GetChangeDetail(change)
290 if not change_detail:
291 raise GerritException('Change %s not found on server %s.'
292 % (change, self.host))
293 patch_dict = cros_patch.GerritPatch.ConvertQueryResults(
294 change_detail, self.host)
295 yield change, cros_patch.GerritPatch(patch_dict, self.remote, url_prefix)
298 def _to_changenum(change):
299 """Unequivocally return a gerrit change number.
301 The argument may either be an number, which will be returned unchanged;
302 or an instance of GerritPatch, in which case its gerrit number will be
305 # TODO(davidjames): Deprecate the ability to pass in strings to these
306 # functions -- API users should just pass in a GerritPatch instead or use
307 # the gob_util APIs directly.
308 if isinstance(change, cros_patch.GerritPatch):
309 return change.gerrit_number
313 def SetReview(self, change, msg=None, labels=None, dryrun=False):
314 """Update the review labels on a gerrit change.
317 change: A gerrit change number.
318 msg: A text comment to post to the review.
319 labels: A dict of label/value to set on the review.
320 dryrun: If True, don't actually update the review.
322 if not msg and not labels:
326 cros_build_lib.Info('Would have added message "%s" to change "%s".',
329 for key, val in labels.iteritems():
331 'Would have set label "%s" to "%s" for change "%s".',
334 gob_util.SetReview(self.host, self._to_changenum(change),
335 msg=msg, labels=labels, notify='ALL')
337 def RemoveCommitReady(self, change, dryrun=False):
338 """Set the 'Commit-Queue' label on a gerrit change to '0'."""
340 cros_build_lib.Info('Would have reset Commit-Queue label for %s', change)
342 gob_util.ResetReviewLabels(self.host, self._to_changenum(change),
343 label='Commit-Queue', notify='OWNER')
345 def SubmitChange(self, change, dryrun=False):
346 """Land (merge) a gerrit change."""
348 cros_build_lib.Info('Would have submitted change %s', change)
350 gob_util.SubmitChange(self.host, change.gerrit_number, revision=change.sha1)
352 def AbandonChange(self, change, dryrun=False):
353 """Mark a gerrit change as 'Abandoned'."""
355 cros_build_lib.Info('Would have abandoned change %s', change)
357 gob_util.AbandonChange(self.host, self._to_changenum(change))
359 def RestoreChange(self, change, dryrun=False):
360 """Re-activate a previously abandoned gerrit change."""
362 cros_build_lib.Info('Would have restored change %s', change)
364 gob_util.RestoreChange(self.host, self._to_changenum(change))
366 def DeleteDraft(self, change, dryrun=False):
367 """Delete a draft patch set."""
369 cros_build_lib.Info('Would have deleted draft patch set %s', change)
371 gob_util.DeleteDraft(self.host, self._to_changenum(change))
374 def GetGerritPatchInfo(patches):
375 """Query Gerrit server for patch information using string queries.
378 patches: A list of patch IDs to query. Internal patches start with a '*'.
381 A list of GerritPatch objects describing each patch. Only the first
382 instance of a requested patch is returned.
385 PatchException if a patch can't be found.
386 ValueError if a query string cannot be converted to a PatchQuery object.
388 return GetGerritPatchInfoWithPatchQueries(
389 [cros_patch.ParsePatchDep(p) for p in patches])
392 def GetGerritPatchInfoWithPatchQueries(patches):
393 """Query Gerrit server for patch information using PatchQuery objects.
396 patches: A list of PatchQuery objects to query.
399 A list of GerritPatch objects describing each patch. Only the first
400 instance of a requested patch is returned.
403 PatchException if a patch can't be found.
407 for remote in constants.CHANGE_PREFIX.keys():
408 helper = GetGerritHelper(remote)
409 raw_ids = [x.ToGerritQueryText() for x in patches
410 if x.remote == remote]
411 for _k, change in helper.QueryMultipleCurrentPatchset(raw_ids):
412 # return a unique list, while maintaining the ordering of the first
413 # seen instance of each patch. Do this to ensure whatever ordering
414 # the user is trying to enforce, we honor; lest it break on
416 if change.id not in seen:
417 results.append(change)
423 def GetGerritHelper(remote=None, gob=None, **kwargs):
424 """Return a GerritHelper instance for interacting with the given remote."""
426 return GerritHelper.FromGob(gob, **kwargs)
428 return GerritHelper.FromRemote(remote, **kwargs)
431 def GetGerritHelperForChange(change):
432 """Return a usable GerritHelper instance for this change.
434 If you need a GerritHelper for a specific change, get it via this
437 return GetGerritHelper(change.remote)
440 def GetCrosInternal(**kwargs):
441 """Convenience method for accessing private ChromeOS gerrit."""
442 return GetGerritHelper(constants.INTERNAL_REMOTE, **kwargs)
445 def GetCrosExternal(**kwargs):
446 """Convenience method for accessing public ChromiumOS gerrit."""
447 return GetGerritHelper(constants.EXTERNAL_REMOTE, **kwargs)
450 def GetChangeRef(change_number, patchset=None):
451 """Given a change number, return the refs/changes/* space for it.
454 change_number: The gerrit change number you want a refspec for.
455 patchset: If given it must either be an integer or '*'. When given,
456 the returned refspec is for that exact patchset. If '*' is given, it's
457 used for pulling down all patchsets for that change.
462 change_number = int(change_number)
463 s = 'refs/changes/%02i/%i' % (change_number % 100, change_number)
464 if patchset is not None:
465 s += '/%s' % ('*' if patchset == '*' else int(patchset))