xref: /aosp_15_r20/external/autotest/utils/frozen_chromite/lib/gob_util.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# -*- coding: utf-8 -*-
2# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Utilities for requesting information for a gerrit server via https.
7
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
11from __future__ import print_function
12
13import datetime
14import json
15import os
16import re
17import socket
18import sys
19import warnings
20
21import httplib2
22try:
23  from oauth2client import gce
24except ImportError:  # Newer oauth2client versions put it in .contrib
25  # pylint: disable=import-error,no-name-in-module
26  from oauth2client.contrib import gce
27import six
28from six.moves import html_parser as HTMLParser
29from six.moves import http_client as httplib
30from six.moves import http_cookiejar as cookielib
31from six.moves import urllib
32
33from autotest_lib.utils.frozen_chromite.lib import auth
34from autotest_lib.utils.frozen_chromite.lib import constants
35from autotest_lib.utils.frozen_chromite.lib import cros_logging as logging
36from autotest_lib.utils.frozen_chromite.lib import git
37from autotest_lib.utils.frozen_chromite.lib import retry_util
38from autotest_lib.utils.frozen_chromite.lib import timeout_util
39from autotest_lib.utils.frozen_chromite.lib import cros_build_lib
40from autotest_lib.utils.frozen_chromite.utils import memoize
41
42
43_GAE_VERSION = 'GAE_VERSION'
44
45
46class ErrorParser(HTMLParser.HTMLParser):
47  """Class to parse GOB error message reported as HTML.
48
49  Only data inside <div id='af-error-container'> section is retrieved from the
50  GOB error message. Retrieved data is processed as follows:
51
52  - newlines are removed
53  - each <br> tag is replaced with '\n'
54  - each <p> tag is replaced with '\n\n'
55  """
56
57  def __init__(self):
58    HTMLParser.HTMLParser.__init__(self)
59    self.in_div = False
60    self.err_data = ''
61
62  def handle_starttag(self, tag, attrs):
63    tag_id = [x[1] for x in attrs if x[0] == 'id']
64    if tag == 'div' and tag_id and tag_id[0] == 'af-error-container':
65      self.in_div = True
66      return
67
68    if self.in_div:
69      if tag == 'p':
70        self.err_data += '\n\n'
71        return
72
73      if tag == 'br':
74        self.err_data += '\n'
75        return
76
77  def handle_endtag(self, tag):
78    if tag == 'div':
79      self.in_div = False
80
81  def handle_data(self, data):
82    if self.in_div:
83      self.err_data += data.replace('\n', '')
84
85  def ParsedDiv(self):
86    return self.err_data.strip()
87
88
89@memoize.Memoize
90def _GetAppCredentials():
91  """Returns the singleton Appengine credentials for gerrit code review."""
92  return gce.AppAssertionCredentials(
93      scope='https://www.googleapis.com/auth/gerritcodereview')
94
95
96TRY_LIMIT = 11
97SLEEP = 0.5
98REQUEST_TIMEOUT_SECONDS = 120  # 2 minutes.
99
100# Controls the transport protocol used to communicate with Gerrit servers using
101# git. This is parameterized primarily to enable cros_test_lib.GerritTestCase.
102GIT_PROTOCOL = 'https'
103
104# The GOB conflict errors which could be ignorable.
105GOB_CONFLICT_ERRORS = (
106    br'change is closed',
107    br'Cannot reduce vote on labels for closed change',
108)
109
110GOB_CONFLICT_ERRORS_RE = re.compile(br'|'.join(GOB_CONFLICT_ERRORS),
111                                    re.IGNORECASE)
112
113GOB_ERROR_REASON_CLOSED_CHANGE = 'CLOSED CHANGE'
114
115
116class GOBError(Exception):
117  """Exception class for errors commuicating with the gerrit-on-borg service."""
118  def __init__(self, http_status=None, reason=None):
119    self.http_status = http_status
120    self.reason = reason
121
122    message = ''
123    if http_status is not None:
124      message += '(http_status): %d' % (http_status,)
125    if reason is not None:
126      message += '(reason): %s' % (reason,)
127    if not message:
128      message = 'Unknown error'
129
130    super(GOBError, self).__init__(message)
131
132
133class InternalGOBError(GOBError):
134  """Exception class for GOB errors with status >= 500"""
135
136
137def _QueryString(param_dict, first_param=None):
138  """Encodes query parameters in the key:val[+key:val...] format specified here:
139
140  https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
141  """
142  q = [urllib.parse.quote(first_param)] if first_param else []
143  q.extend(['%s:%s' % (key, val) for key, val in param_dict.items()])
144  return '+'.join(q)
145
146
147def GetCookies(host, path, cookie_paths=None):
148  """Returns cookies that should be set on a request.
149
150  Used by CreateHttpConn for any requests that do not already specify a Cookie
151  header. All requests made by this library are HTTPS.
152
153  Args:
154    host: The hostname of the Gerrit service.
155    path: The path on the Gerrit service, already including /a/ if applicable.
156    cookie_paths: Files to look in for cookies. Defaults to looking in the
157      standard places where GoB places cookies.
158
159  Returns:
160    A dict of cookie name to value, with no URL encoding applied.
161  """
162  cookies = {}
163  if cookie_paths is None:
164    cookie_paths = (constants.GOB_COOKIE_PATH, constants.GITCOOKIES_PATH)
165  for cookie_path in cookie_paths:
166    if os.path.isfile(cookie_path):
167      with open(cookie_path) as f:
168        for line in f:
169          fields = line.strip().split('\t')
170          if line.strip().startswith('#') or len(fields) != 7:
171            continue
172          domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
173          if cookielib.domain_match(host, domain) and path.startswith(xpath):
174            cookies[key] = value
175  return cookies
176
177
178def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
179  """Opens an https connection to a gerrit service, and sends a request."""
180  path = '/a/' + path.lstrip('/')
181  headers = headers or {}
182  if _InAppengine():
183    # TODO(phobbs) how can we choose to only run this on GCE / AppEngine?
184    credentials = _GetAppCredentials()
185    try:
186      headers.setdefault(
187          'Authorization',
188          'Bearer %s' % credentials.get_access_token().access_token)
189    except gce.HttpAccessTokenRefreshError as e:
190      logging.debug('Failed to retreive gce access token: %s', e)
191    # Not in an Appengine or GCE environment.
192    except httplib2.ServerNotFoundError as e:
193      pass
194
195  cookies = GetCookies(host, path)
196  if 'Cookie' not in headers and cookies:
197    headers['Cookie'] = '; '.join('%s=%s' % (n, v) for n, v in cookies.items())
198  elif 'Authorization' not in headers:
199    try:
200      git_creds = auth.GitCreds()
201    except auth.AccessTokenError:
202      git_creds = None
203    if git_creds:
204      headers.setdefault('Authorization', 'Bearer %s' % git_creds)
205    else:
206      logging.debug(
207          'No gitcookies file, Appengine credentials, or LUCI git creds found.')
208
209  if 'User-Agent' not in headers:
210    # We may not be in a git repository.
211    try:
212      version = git.GetGitRepoRevision(
213          os.path.dirname(os.path.realpath(__file__)))
214    except cros_build_lib.RunCommandError:
215      version = 'unknown'
216    headers['User-Agent'] = ' '.join((
217        'autotest.chromite.lib.gob_util',
218        os.path.basename(sys.argv[0]),
219        version,
220    ))
221
222  if body:
223    body = json.JSONEncoder().encode(body)
224    headers.setdefault('Content-Type', 'application/json')
225  if logging.getLogger().isEnabledFor(logging.DEBUG):
226    logging.debug('%s https://%s%s', reqtype, host, path)
227    for key, val in headers.items():
228      if key.lower() in ('authorization', 'cookie'):
229        val = 'HIDDEN'
230      logging.debug('%s: %s', key, val)
231    if body:
232      logging.debug(body)
233  conn = httplib.HTTPSConnection(host)
234  conn.req_host = host
235  conn.req_params = {
236      'url': path,
237      'method': reqtype,
238      'headers': headers,
239      'body': body,
240  }
241  conn.request(**conn.req_params)
242  return conn
243
244
245def _InAppengine():
246  """Returns whether we're in the Appengine environment."""
247  return _GAE_VERSION in os.environ
248
249
250def FetchUrl(host, path, reqtype='GET', headers=None, body=None,
251             ignore_204=False, ignore_404=True):
252  """Fetches the http response from the specified URL.
253
254  Args:
255    host: The hostname of the Gerrit service.
256    path: The path on the Gerrit service. This will be prefixed with '/a'
257          automatically.
258    reqtype: The request type. Can be GET or POST.
259    headers: A mapping of extra HTTP headers to pass in with the request.
260    body: A string of data to send after the headers are finished.
261    ignore_204: for some requests gerrit-on-borg will return 204 to confirm
262                proper processing of the request. When processing responses to
263                these requests we should expect this status.
264    ignore_404: For many requests, gerrit-on-borg will return 404 if the request
265                doesn't match the database contents.  In most such cases, we
266                want the API to return None rather than raise an Exception.
267
268  Returns:
269    The connection's reply, as bytes.
270  """
271  @timeout_util.TimeoutDecorator(REQUEST_TIMEOUT_SECONDS)
272  def _FetchUrlHelper():
273    err_prefix = 'A transient error occured while querying %s:\n' % (host,)
274    try:
275      conn = CreateHttpConn(host, path, reqtype=reqtype, headers=headers,
276                            body=body)
277      response = conn.getresponse()
278    except socket.error as ex:
279      logging.warning('%s%s', err_prefix, str(ex))
280      raise
281
282    # Normal/good responses.
283    response_body = response.read()
284    if response.status == 204 and ignore_204:
285      # This exception is used to confirm expected response status.
286      raise GOBError(http_status=response.status, reason=response.reason)
287    if response.status == 404 and ignore_404:
288      return b''
289    elif response.status == 200:
290      return response_body
291
292    # Bad responses.
293    logging.debug('response msg:\n%s', response.msg)
294    http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
295    msg = ('%s %s %s\n%s %d %s\nResponse body: %r' %
296           (reqtype, conn.req_params['url'], http_version,
297            http_version, response.status, response.reason,
298            response_body))
299
300    # Ones we can retry.
301    if response.status >= 500:
302      # A status >=500 is assumed to be a possible transient error; retry.
303      logging.warning('%s%s', err_prefix, msg)
304      raise InternalGOBError(
305          http_status=response.status,
306          reason=response.reason)
307
308    # Ones we cannot retry.
309    home = os.environ.get('HOME', '~')
310    url = 'https://%s/new-password' % host
311    if response.status in (302, 303, 307):
312      err_prefix = ('Redirect found; missing/bad %s/.gitcookies credentials or '
313                    'permissions (0600)?\n See %s' % (home, url))
314    elif response.status in (400,):
315      err_prefix = 'Permission error; talk to the admins of the GoB instance'
316    elif response.status in (401,):
317      err_prefix = ('Authorization error; missing/bad %s/.gitcookies '
318                    'credentials or permissions (0600)?\n See %s' % (home, url))
319    elif response.status in (422,):
320      err_prefix = ('Bad request body?')
321
322    logging.warning(err_prefix)
323
324    # If GOB output contained expected error message, reduce log visibility of
325    # raw GOB output reported below.
326    ep = ErrorParser()
327    ep.feed(response_body.decode('utf-8'))
328    ep.close()
329    parsed_div = ep.ParsedDiv()
330    if parsed_div:
331      logging.warning('GOB Error:\n%s', parsed_div)
332      logging_function = logging.debug
333    else:
334      logging_function = logging.warning
335
336    logging_function(msg)
337    if response.status >= 400:
338      # The 'X-ErrorId' header is set only on >= 400 response code.
339      logging_function('X-ErrorId: %s', response.getheader('X-ErrorId'))
340
341    try:
342      logging.warning('conn.sock.getpeername(): %s', conn.sock.getpeername())
343    except AttributeError:
344      logging.warning('peer name unavailable')
345
346    if response.status == httplib.CONFLICT:
347      # 409 conflict
348      if GOB_CONFLICT_ERRORS_RE.search(response_body):
349        raise GOBError(
350            http_status=response.status,
351            reason=GOB_ERROR_REASON_CLOSED_CHANGE)
352      else:
353        raise GOBError(http_status=response.status, reason=response.reason)
354    else:
355      raise GOBError(http_status=response.status, reason=response.reason)
356
357  return retry_util.RetryException(
358      (socket.error, InternalGOBError, timeout_util.TimeoutError),
359      TRY_LIMIT,
360      _FetchUrlHelper, sleep=SLEEP, backoff_factor=2)
361
362
363def FetchUrlJson(*args, **kwargs):
364  """Fetch the specified URL and parse it as JSON.
365
366  See FetchUrl for arguments.
367  """
368  fh = FetchUrl(*args, **kwargs)
369
370  # In case ignore_404 is True, we want to return None instead of
371  # raising an exception.
372  if not fh:
373    return None
374
375  # The first line of the response should always be: )]}'
376  if not fh.startswith(b")]}'"):
377    raise GOBError(http_status=200, reason='Unexpected json output: %r' % fh)
378
379  _, _, json_data = fh.partition(b'\n')
380  return json.loads(json_data)
381
382
383def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
384                 start=None):
385  """Queries a gerrit-on-borg server for changes matching query terms.
386
387  Args:
388    host: The Gerrit server hostname.
389    param_dict: A dictionary of search parameters, as documented here:
390        https://gerrit-review.googlesource.com/Documentation/user-search.html
391    first_param: A change identifier
392    limit: Maximum number of results to return.
393    o_params: A list of additional output specifiers, as documented here:
394        https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
395    start: Offset in the result set to start at.
396
397  Returns:
398    A list of json-decoded query results.
399  """
400  # Note that no attempt is made to escape special characters; YMMV.
401  if not param_dict and not first_param:
402    raise RuntimeError('QueryChanges requires search parameters')
403  path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
404  if start:
405    path = '%s&S=%d' % (path, start)
406  if limit:
407    path = '%s&n=%d' % (path, limit)
408  if o_params:
409    path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
410  # Don't ignore 404; a query should always return a list, even if it's empty.
411  return FetchUrlJson(host, path, ignore_404=False)
412
413
414def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
415                      start=None):
416  """Initiate a query composed of multiple sets of query parameters."""
417  if not change_list:
418    raise RuntimeError(
419        "MultiQueryChanges requires a list of change numbers/id's")
420  q = ['q=%s' % '+OR+'.join(urllib.parse.quote(str(x)) for x in change_list)]
421  if param_dict:
422    q.append(_QueryString(param_dict))
423  if limit:
424    q.append('n=%d' % limit)
425  if start:
426    q.append('S=%s' % start)
427  if o_params:
428    q.extend(['o=%s' % p for p in o_params])
429  path = 'changes/?%s' % '&'.join(q)
430  try:
431    result = FetchUrlJson(host, path, ignore_404=False)
432  except GOBError as e:
433    msg = '%s:\n%s' % (e, path)
434    raise GOBError(http_status=e.http_status, reason=msg)
435  return result
436
437
438def GetGerritFetchUrl(host):
439  """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
440  return 'https://%s/' % host
441
442
443def GetChangePageUrl(host, change_number):
444  """Given a gerrit host name and change number, return change page url."""
445  return 'https://%s/#/c/%d/' % (host, change_number)
446
447
448def _GetChangePath(change):
449  """Given a change id, return a path prefix for the change."""
450  return 'changes/%s' % str(change).replace('/', '%2F')
451
452
453def GetChangeUrl(host, change):
454  """Given a gerrit host name and change id, return an url for the change."""
455  return 'https://%s/a/%s' % (host, _GetChangePath(change))
456
457
458def GetChange(host, change):
459  """Query a gerrit server for information about a single change."""
460  return FetchUrlJson(host, _GetChangePath(change))
461
462
463def GetChangeReview(host, change, revision=None):
464  """Get the current review information for a change."""
465  if revision is None:
466    revision = 'current'
467  path = '%s/revisions/%s/review' % (_GetChangePath(change), revision)
468  return FetchUrlJson(host, path)
469
470
471def GetChangeCommit(host, change, revision=None):
472  """Get the current review information for a change."""
473  if revision is None:
474    revision = 'current'
475  path = '%s/revisions/%s/commit' % (_GetChangePath(change), revision)
476  return FetchUrlJson(host, path)
477
478
479def GetChangeCurrentRevision(host, change):
480  """Get information about the latest revision for a given change."""
481  jmsg = GetChangeReview(host, change)
482  if jmsg:
483    return jmsg.get('current_revision')
484
485
486def GetChangeDetail(host, change, o_params=None):
487  """Query a gerrit server for extended information about a single change."""
488  path = '%s/detail' % _GetChangePath(change)
489  if o_params:
490    path = '%s?%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
491  return FetchUrlJson(host, path)
492
493
494def GetChangeReviewers(host, change):
495  """Get information about all reviewers attached to a change.
496
497  Args:
498    host: The Gerrit host to interact with.
499    change: The Gerrit change ID.
500  """
501  warnings.warn('GetChangeReviewers is deprecated; use GetReviewers instead.')
502  GetReviewers(host, change)
503
504
505def ReviewedChange(host, change):
506  """Mark a gerrit change as reviewed."""
507  path = '%s/reviewed' % _GetChangePath(change)
508  return FetchUrlJson(host, path, reqtype='PUT', ignore_404=False)
509
510
511def UnreviewedChange(host, change):
512  """Mark a gerrit change as unreviewed."""
513  path = '%s/unreviewed' % _GetChangePath(change)
514  return FetchUrlJson(host, path, reqtype='PUT', ignore_404=False)
515
516
517def IgnoreChange(host, change):
518  """Ignore a gerrit change."""
519  path = '%s/ignore' % _GetChangePath(change)
520  return FetchUrlJson(host, path, reqtype='PUT', ignore_404=False)
521
522
523def UnignoreChange(host, change):
524  """Unignore a gerrit change."""
525  path = '%s/unignore' % _GetChangePath(change)
526  return FetchUrlJson(host, path, reqtype='PUT', ignore_404=False)
527
528
529def AbandonChange(host, change, msg=''):
530  """Abandon a gerrit change."""
531  path = '%s/abandon' % _GetChangePath(change)
532  body = {'message': msg}
533  return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
534
535
536def RestoreChange(host, change, msg=''):
537  """Restore a previously abandoned change."""
538  path = '%s/restore' % _GetChangePath(change)
539  body = {'message': msg}
540  return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
541
542
543def DeleteDraft(host, change):
544  """Delete a gerrit draft change."""
545  path = _GetChangePath(change)
546  try:
547    FetchUrl(host, path, reqtype='DELETE', ignore_204=True, ignore_404=False)
548  except GOBError as e:
549    # On success, gerrit returns status 204; anything else is an error.
550    if e.http_status != 204:
551      raise
552  else:
553    raise GOBError(
554        http_status=200,
555        reason='Unexpectedly received a 200 http status while deleting draft '
556               ' %r' % change)
557
558
559def SubmitChange(host, change, revision=None, wait_for_merge=True):
560  """Submits a gerrit change via Gerrit."""
561  if revision is None:
562    revision = 'current'
563  path = '%s/revisions/%s/submit' % (_GetChangePath(change), revision)
564  body = {'wait_for_merge': wait_for_merge}
565  return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
566
567
568def CheckChange(host, change, sha1=None):
569  """Performs consistency checks on the change, and fixes inconsistencies.
570
571  This is useful for forcing Gerrit to check whether a change has already been
572  merged into the git repo. Namely, if |sha1| is provided and the change is in
573  'NEW' status, Gerrit will check if a change with that |sha1| is in the repo
574  and mark the change as 'MERGED' if it exists.
575
576  Args:
577    host: The Gerrit host to interact with.
578    change: The Gerrit change ID.
579    sha1: An optional hint of the commit's SHA1 in Git.
580  """
581  path = '%s/check' % (_GetChangePath(change),)
582  if sha1:
583    body, headers = {'expect_merged_as': sha1}, {}
584  else:
585    body, headers = {}, {'Content-Length': '0'}
586
587  return FetchUrlJson(host, path, reqtype='POST',
588                      body=body, ignore_404=False,
589                      headers=headers)
590
591
592def GetAssignee(host, change):
593  """Get assignee for a change."""
594  path = '%s/assignee' % _GetChangePath(change)
595  return FetchUrlJson(host, path)
596
597
598def AddAssignee(host, change, assignee):
599  """Add reviewers to a change.
600
601  Args:
602    host: The Gerrit host to interact with.
603    change: The Gerrit change ID.
604    assignee: Gerrit account email as a string
605  """
606  path = '%s/assignee' % _GetChangePath(change)
607  body = {'assignee': assignee}
608  return  FetchUrlJson(host, path, reqtype='PUT', body=body, ignore_404=False)
609
610
611def MarkPrivate(host, change):
612  """Marks the given CL as private.
613
614  Args:
615    host: The gob host to interact with.
616    change: CL number on the given host.
617  """
618  path = '%s/private' % _GetChangePath(change)
619  try:
620    FetchUrlJson(host, path, reqtype='POST', ignore_404=False)
621  except GOBError as e:
622    # 201: created -- change was successfully marked private.
623    if e.http_status != 201:
624      raise
625  else:
626    raise GOBError(
627        http_status=200,
628        reason='Change was already marked private',
629    )
630
631
632def MarkNotPrivate(host, change):
633  """Sets the private bit on given CL to False.
634
635  Args:
636    host: The gob host to interact with.
637    change: CL number on the given host.
638  """
639  path = '%s/private.delete' % _GetChangePath(change)
640  try:
641    FetchUrlJson(host, path, reqtype='POST', ignore_404=False, ignore_204=True)
642  except GOBError as e:
643    if e.http_status == 204:
644      # 204: no content -- change was successfully marked not private.
645      pass
646    elif e.http_status == 409:
647      raise GOBError(
648          http_status=e.http_status,
649          reason='Change was already marked not private',
650      )
651    else:
652      raise
653  else:
654    raise GOBError(
655        http_status=200,
656        reason='Got unexpected 200 when marking change not private.',
657    )
658
659
660def GetReviewers(host, change):
661  """Get information about all reviewers attached to a change.
662
663  Args:
664    host: The Gerrit host to interact with.
665    change: The Gerrit change ID.
666  """
667  path = '%s/reviewers' % _GetChangePath(change)
668  return FetchUrlJson(host, path)
669
670
671def AddReviewers(host, change, add=None, notify=None):
672  """Add reviewers to a change."""
673  if not add:
674    return
675  if isinstance(add, six.string_types):
676    add = (add,)
677  body = {}
678  if notify:
679    body['notify'] = notify
680  path = '%s/reviewers' % _GetChangePath(change)
681  for r in add:
682    body['reviewer'] = r
683    jmsg = FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
684  return jmsg
685
686
687def RemoveReviewers(host, change, remove=None, notify=None):
688  """Remove reveiewers from a change."""
689  if not remove:
690    return
691  if isinstance(remove, six.string_types):
692    remove = (remove,)
693  body = {}
694  if notify:
695    body['notify'] = notify
696  for r in remove:
697    path = '%s/reviewers/%s/delete' % (_GetChangePath(change), r)
698    try:
699      FetchUrl(host, path, reqtype='POST', body=body, ignore_204=True)
700    except GOBError as e:
701      # On success, gerrit returns status 204; anything else is an error.
702      if e.http_status != 204:
703        raise
704
705
706def SetReview(host, change, revision=None, msg=None, labels=None, notify=None):
707  """Set labels and/or add a message to a code review."""
708  if revision is None:
709    revision = 'current'
710  if not msg and not labels:
711    return
712  path = '%s/revisions/%s/review' % (_GetChangePath(change), revision)
713  body = {}
714  if msg:
715    body['message'] = msg
716  if labels:
717    body['labels'] = labels
718  if notify:
719    body['notify'] = notify
720  response = FetchUrlJson(host, path, reqtype='POST', body=body)
721  if response is None:
722    raise GOBError(
723        http_status=404,
724        reason='CL %s not found in %s' % (change, host))
725  if labels:
726    for key, val in labels.items():
727      if ('labels' not in response or key not in response['labels'] or
728          int(response['labels'][key] != int(val))):
729        raise GOBError(
730            http_status=200,
731            reason='Unable to set "%s" label on change %s.' % (key, change))
732
733
734def SetTopic(host, change, topic):
735  """Set |topic| for a change. If |topic| is empty, it will be deleted"""
736  path = '%s/topic' % _GetChangePath(change)
737  body = {'topic': topic}
738  return FetchUrlJson(host, path, reqtype='PUT', body=body, ignore_404=False)
739
740
741def SetHashtags(host, change, add, remove):
742  """Adds and / or removes hashtags from a change.
743
744  Args:
745    host: Hostname (without protocol prefix) of the gerrit server.
746    change: A gerrit change number.
747    add: a list of hashtags to be added.
748    remove: a list of hashtags to be removed.
749  """
750  path = '%s/hashtags' % _GetChangePath(change)
751  return FetchUrlJson(host, path, reqtype='POST',
752                      body={'add': add, 'remove': remove},
753                      ignore_404=False)
754
755
756def ResetReviewLabels(host, change, label, value='0', revision=None,
757                      message=None, notify=None):
758  """Reset the value of a given label for all reviewers on a change."""
759  if revision is None:
760    revision = 'current'
761  # This is tricky when working on the "current" revision, because there's
762  # always the risk that the "current" revision will change in between API
763  # calls.  So, the code dereferences the "current" revision down to a literal
764  # sha1 at the beginning and uses it for all subsequent calls.  As a sanity
765  # check, the "current" revision is dereferenced again at the end, and if it
766  # differs from the previous "current" revision, an exception is raised.
767  current = (revision == 'current')
768  jmsg = GetChangeDetail(
769      host, change, o_params=['CURRENT_REVISION', 'CURRENT_COMMIT'])
770  if current:
771    revision = jmsg['current_revision']
772  value = str(value)
773  path = '%s/revisions/%s/review' % (_GetChangePath(change), revision)
774  message = message or (
775      '%s label set to %s programmatically by chromite.' % (label, value))
776  for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
777    if str(review.get('value', value)) != value:
778      body = {
779          'message': message,
780          'labels': {label: value},
781          'on_behalf_of': review['_account_id'],
782      }
783      if notify:
784        body['notify'] = notify
785      response = FetchUrlJson(host, path, reqtype='POST', body=body)
786      if str(response['labels'][label]) != value:
787        username = review.get('email', jmsg.get('name', ''))
788        raise GOBError(
789            http_status=200,
790            reason='Unable to set %s label for user "%s" on change %s.' % (
791                label, username, change))
792  if current:
793    new_revision = GetChangeCurrentRevision(host, change)
794    if not new_revision:
795      raise GOBError(
796          http_status=200,
797          reason='Could not get review information for change "%s"' % change)
798    elif new_revision != revision:
799      raise GOBError(
800          http_status=200,
801          reason='While resetting labels on change "%s", a new patchset was '
802                 'uploaded.' % change)
803
804
805def GetTipOfTrunkRevision(git_url):
806  """Returns the current git revision on the master branch."""
807  parsed_url = urllib.parse.urlparse(git_url)
808  path = parsed_url[2].rstrip('/') + '/+log/master?n=1&format=JSON'
809  j = FetchUrlJson(parsed_url[1], path, ignore_404=False)
810  if not j:
811    raise GOBError(
812        reason='Could not find revision information from %s' % git_url)
813  try:
814    return j['log'][0]['commit']
815  except (IndexError, KeyError, TypeError):
816    msg = ('The json returned by https://%s%s has an unfamiliar structure:\n'
817           '%s\n' % (parsed_url[1], path, j))
818    raise GOBError(reason=msg)
819
820
821def GetCommitDate(git_url, commit):
822  """Returns the date of a particular git commit.
823
824  The returned object is naive in the sense that it doesn't carry any timezone
825  information - you should assume UTC.
826
827  Args:
828    git_url: URL for the repository to get the commit date from.
829    commit: A git commit identifier (e.g. a sha1).
830
831  Returns:
832    A datetime object.
833  """
834  parsed_url = urllib.parse.urlparse(git_url)
835  path = '%s/+log/%s?n=1&format=JSON' % (parsed_url.path.rstrip('/'), commit)
836  j = FetchUrlJson(parsed_url.netloc, path, ignore_404=False)
837  if not j:
838    raise GOBError(
839        reason='Could not find revision information from %s' % git_url)
840  try:
841    commit_timestr = j['log'][0]['committer']['time']
842  except (IndexError, KeyError, TypeError):
843    msg = ('The json returned by https://%s%s has an unfamiliar structure:\n'
844           '%s\n' % (parsed_url.netloc, path, j))
845    raise GOBError(reason=msg)
846  try:
847    # We're parsing a string of the form 'Tue Dec 02 17:48:06 2014'.
848    return datetime.datetime.strptime(commit_timestr,
849                                      constants.GOB_COMMIT_TIME_FORMAT)
850  except ValueError:
851    raise GOBError(reason='Failed parsing commit time "%s"' % commit_timestr)
852
853
854def GetAccount(host):
855  """Get information about the user account."""
856  return FetchUrlJson(host, 'accounts/self')
857