Upstream version 10.39.225.0
[platform/framework/web/crosswalk.git] / src / third_party / chromite / lib / gerrit.py
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.
4
5 """Module containing helper class and methods for interacting with Gerrit."""
6
7 from __future__ import print_function
8
9 import operator
10
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
16
17 gob_util.LOGGER = cros_build_lib.logger
18
19
20 class GerritException(Exception):
21   """Base exception, thrown for gerrit failures"""
22
23
24 class QueryHasNoResults(GerritException):
25   """Exception thrown when a query returns no results."""
26
27
28 class QueryNotSpecific(GerritException):
29   """Thrown when a query needs to identify one CL, but matched multiple."""
30
31
32 class FailedToReachGerrit(GerritException):
33   """Exception thrown if we failed to contact the Gerrit server."""
34
35
36 class GerritHelper(object):
37   """Helper class to manage interaction with the gerrit-on-borg service."""
38
39   # Maximum number of results to return per query.
40   _GERRIT_MAX_QUERY_RETURN = 500
41
42   # Fields that appear in gerrit change query results.
43   MORE_CHANGES = '_more_changes'
44
45   def __init__(self, host, remote, print_cmd=True):
46     """Initialize.
47
48     Args:
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.
54     """
55     self.host = host
56     self.remote = remote
57     self.print_cmd = bool(print_cmd)
58     self._version = None
59
60   @classmethod
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
66     else:
67       raise ValueError('Remote %s not supported.' % remote)
68     return cls(host, remote, **kwargs)
69
70   @classmethod
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)
75
76   def SetReviewers(self, change, add=(), remove=(), dryrun=False):
77     """Modify the list of reviewers on a gerrit change.
78
79     Args:
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.
84     """
85     if add:
86       if dryrun:
87         cros_build_lib.Info('Would have added %s to "%s"', add, change)
88       else:
89         gob_util.AddReviewers(self.host, change, add)
90     if remove:
91       if dryrun:
92         cros_build_lib.Info('Would have removed %s to "%s"', remove, change)
93       else:
94         gob_util.RemoveReviewers(self.host, change, remove)
95
96   def GetChangeDetail(self, change_num):
97     """Return detailed information about a gerrit change.
98
99     Args:
100       change_num: A gerrit change number.
101     """
102     return gob_util.GetChangeDetail(
103         self.host, change_num, o_params=('CURRENT_REVISION', 'CURRENT_COMMIT'))
104
105   def GrabPatchFromGerrit(self, project, change, commit, must_match=True):
106     """Return a cros_patch.GerritPatch representing a gerrit change.
107
108     Args:
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.
113     """
114     query = { 'project': project, 'commit': commit, 'must_match': must_match }
115     return self.QuerySingleRecord(change, **query)
116
117   def IsChangeCommitted(self, change, must_match=False):
118     """Check whether a gerrit change has been merged.
119
120     Args:
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.
124     """
125     change = gob_util.GetChange(self.host, change)
126     if not change:
127       if must_match:
128         raise QueryHasNoResults('Could not query for change %s' % change)
129       return
130     return change.get('status') == 'MERGED'
131
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]
136     try:
137       result = git.RunGit('.', cmd, print_cmd=self.print_cmd)
138       if result:
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),
142                            exc_info=True)
143
144   def QuerySingleRecord(self, change=None, **kwargs):
145     """Free-form query of a gerrit change that expects a single result.
146
147     Args:
148       change: A gerrit change number.
149       **kwargs:
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.
154
155     Returns:
156       If kwargs['raw'] == True, return a python dict representing the
157       change; otherwise, return a cros_patch.GerritPatch object.
158     """
159     query_kwds = kwargs
160     dryrun = query_kwds.get('dryrun')
161     must_match = query_kwds.pop('must_match', True)
162     results = self.Query(change, **query_kwds)
163     if dryrun:
164       return None
165     elif not results:
166       if must_match:
167         raise QueryHasNoResults('Query %s had no results' % (change,))
168       return None
169     elif len(results) != 1:
170       raise QueryNotSpecific('Query %s returned too many results: %s'
171                              % (change, results))
172     return results[0]
173
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.
177
178     Args:
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
182           sorted.
183       current_patch: If True, ask the gerrit server for extra information about
184           the latest uploaded patch.
185       options: Deprecated.
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
193
194     Returns:
195       A list of python dicts or cros_patch.GerritChange.
196     """
197     query_kwds = kwargs
198     if options:
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']
205     if current_patch:
206       o_params.extend(['CURRENT_COMMIT', 'CURRENT_REVISION'])
207
208     if change and cros_patch.ParseGerritNumber(change) and not query_kwds:
209       if dryrun:
210         cros_build_lib.Info('Would have run gob_util.GetChangeDetail(%s, %s)',
211                             self.host, change)
212         return []
213       change = self.GetChangeDetail(change)
214       if change is None:
215         return []
216       patch_dict = cros_patch.GerritPatch.ConvertQueryResults(change, self.host)
217       if raw:
218         return [patch_dict]
219       return [cros_patch.GerritPatch(patch_dict, self.remote, url_prefix)]
220
221     # TODO: We should allow querying using a cros_patch.PatchQuery
222     # object directly.
223     if change and cros_patch.ParseSHA1(change):
224       # Use commit:sha1 for accurate query results (crbug.com/358381).
225       kwargs['commit'] = change
226       change = None
227     elif change and cros_patch.ParseChangeID(change):
228       # Use change:change-id for accurate query results (crbug.com/357876).
229       kwargs['change'] = change
230       change = None
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
236       change = None
237
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')
241
242     if dryrun:
243       cros_build_lib.Info(
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)
247       return []
248
249     start = 0
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)
253     result = list(moar)
254     while moar and self.MORE_CHANGES in moar[-1]:
255       start += len(moar)
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)
259       result.extend(moar)
260
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
265     if bypass_cache:
266       result = [self.GetChangeDetail(x['_number']) for x in result]
267
268     result = [cros_patch.GerritPatch.ConvertQueryResults(
269         x, self.host) for x in result]
270     if sort:
271       result = sorted(result, key=operator.itemgetter(sort))
272     if raw:
273       return result
274     return [cros_patch.GerritPatch(x, self.remote, url_prefix) for x in result]
275
276   def QueryMultipleCurrentPatchset(self, changes):
277     """Query the gerrit server for multiple changes.
278
279     Args:
280       changes: A sequence of gerrit change numbers.
281
282     Returns:
283       A list of cros_patch.GerritPatch.
284     """
285     if not changes:
286       return
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)
296
297   @staticmethod
298   def _to_changenum(change):
299     """Unequivocally return a gerrit change number.
300
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
303     returned.
304     """
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
310
311     return change
312
313   def SetReview(self, change, msg=None, labels=None, dryrun=False):
314     """Update the review labels on a gerrit change.
315
316     Args:
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.
321     """
322     if not msg and not labels:
323       return
324     if dryrun:
325       if msg:
326         cros_build_lib.Info('Would have added message "%s" to change "%s".',
327                             msg, change)
328       if labels:
329         for key, val in labels.iteritems():
330           cros_build_lib.Info(
331               'Would have set label "%s" to "%s" for change "%s".',
332               key, val, change)
333       return
334     gob_util.SetReview(self.host, self._to_changenum(change),
335                        msg=msg, labels=labels, notify='ALL')
336
337   def RemoveCommitReady(self, change, dryrun=False):
338     """Set the 'Commit-Queue' label on a gerrit change to '0'."""
339     if dryrun:
340       cros_build_lib.Info('Would have reset Commit-Queue label for %s', change)
341       return
342     gob_util.ResetReviewLabels(self.host, self._to_changenum(change),
343                                label='Commit-Queue', notify='OWNER')
344
345   def SubmitChange(self, change, dryrun=False):
346     """Land (merge) a gerrit change."""
347     if dryrun:
348       cros_build_lib.Info('Would have submitted change %s', change)
349       return
350     gob_util.SubmitChange(self.host, change.gerrit_number, revision=change.sha1)
351
352   def AbandonChange(self, change, dryrun=False):
353     """Mark a gerrit change as 'Abandoned'."""
354     if dryrun:
355       cros_build_lib.Info('Would have abandoned change %s', change)
356       return
357     gob_util.AbandonChange(self.host, self._to_changenum(change))
358
359   def RestoreChange(self, change, dryrun=False):
360     """Re-activate a previously abandoned gerrit change."""
361     if dryrun:
362       cros_build_lib.Info('Would have restored change %s', change)
363       return
364     gob_util.RestoreChange(self.host, self._to_changenum(change))
365
366   def DeleteDraft(self, change, dryrun=False):
367     """Delete a draft patch set."""
368     if dryrun:
369       cros_build_lib.Info('Would have deleted draft patch set %s', change)
370       return
371     gob_util.DeleteDraft(self.host, self._to_changenum(change))
372
373
374 def GetGerritPatchInfo(patches):
375   """Query Gerrit server for patch information using string queries.
376
377   Args:
378     patches: A list of patch IDs to query. Internal patches start with a '*'.
379
380   Returns:
381     A list of GerritPatch objects describing each patch.  Only the first
382     instance of a requested patch is returned.
383
384   Raises:
385     PatchException if a patch can't be found.
386     ValueError if a query string cannot be converted to a PatchQuery object.
387   """
388   return GetGerritPatchInfoWithPatchQueries(
389       [cros_patch.ParsePatchDep(p) for p in patches])
390
391
392 def GetGerritPatchInfoWithPatchQueries(patches):
393   """Query Gerrit server for patch information using PatchQuery objects.
394
395   Args:
396     patches: A list of PatchQuery objects to query.
397
398   Returns:
399     A list of GerritPatch objects describing each patch.  Only the first
400     instance of a requested patch is returned.
401
402   Raises:
403     PatchException if a patch can't be found.
404   """
405   seen = set()
406   results = []
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
415       # cherry-picking.
416       if change.id not in seen:
417         results.append(change)
418         seen.add(change.id)
419
420   return results
421
422
423 def GetGerritHelper(remote=None, gob=None, **kwargs):
424   """Return a GerritHelper instance for interacting with the given remote."""
425   if gob:
426     return GerritHelper.FromGob(gob, **kwargs)
427   else:
428     return GerritHelper.FromRemote(remote, **kwargs)
429
430
431 def GetGerritHelperForChange(change):
432   """Return a usable GerritHelper instance for this change.
433
434   If you need a GerritHelper for a specific change, get it via this
435   function.
436   """
437   return GetGerritHelper(change.remote)
438
439
440 def GetCrosInternal(**kwargs):
441   """Convenience method for accessing private ChromeOS gerrit."""
442   return GetGerritHelper(constants.INTERNAL_REMOTE, **kwargs)
443
444
445 def GetCrosExternal(**kwargs):
446   """Convenience method for accessing public ChromiumOS gerrit."""
447   return GetGerritHelper(constants.EXTERNAL_REMOTE, **kwargs)
448
449
450 def GetChangeRef(change_number, patchset=None):
451   """Given a change number, return the refs/changes/* space for it.
452
453   Args:
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.
458
459   Returns:
460     A git refspec.
461   """
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))
466   return s