blob: 8af187b618355ccb82fdb82f32b6b73b6ff1c9c3 [file] [log] [blame]
szager@chromium.orgb4696232013-10-16 19:45:35 +00001# Copyright (c) 2013 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"""
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00006Utilities for requesting information for a Gerrit server via HTTPS.
szager@chromium.orgb4696232013-10-16 19:45:35 +00007
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
Raul Tambre80ee78e2019-05-06 22:41:05 +000011from __future__ import print_function
12
szager@chromium.orgb4696232013-10-16 19:45:35 +000013import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080014import contextlib
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000015import cookielib
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010016import httplib # Still used for its constants.
Edward Lemur202c5592019-10-21 22:44:52 +000017import httplib2
szager@chromium.orgb4696232013-10-16 19:45:35 +000018import json
19import logging
20import netrc
21import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000022import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000023import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000024import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000025import stat
26import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080027import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000028import time
29import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000030import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000031from cStringIO import StringIO
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000032from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000033
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070034import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080035import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000036import metrics
37import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080038import subprocess2
szager@chromium.orgf202a252014-05-27 18:55:52 +000039
szager@chromium.orgb4696232013-10-16 19:45:35 +000040LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000041# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
42# total tries, the sleep time between the first and last tries will be 94.5 sec.
43# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
44TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000045
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000046
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +000047# Controls the transport protocol used to communicate with Gerrit.
szager@chromium.orgb4696232013-10-16 19:45:35 +000048# This is parameterized primarily to enable GerritTestCase.
49GERRIT_PROTOCOL = 'https'
50
51
52class GerritError(Exception):
53 """Exception class for errors commuicating with the gerrit-on-borg service."""
54 def __init__(self, http_status, *args, **kwargs):
55 super(GerritError, self).__init__(*args, **kwargs)
56 self.http_status = http_status
57 self.message = '(%d) %s' % (self.http_status, self.message)
58
59
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000060class GerritAuthenticationError(GerritError):
61 """Exception class for authentication errors during Gerrit communication."""
62
63
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020064def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000065 """Encodes query parameters in the key:val[+key:val...] format specified here:
66
67 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
68 """
69 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020070 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000071 return '+'.join(q)
72
73
Aaron Gabled2db5a22017-03-24 14:14:15 -070074def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000075 if protocol is None:
76 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010077 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070078 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000079 else:
80 raise RuntimeError(
81 "Don't know how to work with protocol '%s'" % protocol)
82
83
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000084class Authenticator(object):
85 """Base authenticator class for authenticator implementations to subclass."""
86
87 def get_auth_header(self, host):
88 raise NotImplementedError()
89
90 @staticmethod
91 def get():
92 """Returns: (Authenticator) The identified Authenticator to use.
93
94 Probes the local system and its environment and identifies the
95 Authenticator instance to use.
96 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070097 # LUCI Context takes priority since it's normally present only on bots,
98 # which then must use it.
99 if LuciContextAuthenticator.is_luci():
100 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000101 if GceAuthenticator.is_gce():
102 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000103 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000104
105
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000106class CookiesAuthenticator(Authenticator):
107 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
108
109 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000110 """
111
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000112 _EMPTY = object()
113
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000114 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000115 # Credentials will be loaded lazily on first use. This ensures Authenticator
116 # get() can always construct an authenticator, even if something is broken.
117 # This allows 'creds-check' to proceed to actually checking creds later,
118 # rigorously (instead of blowing up with a cryptic error if they are wrong).
119 self._netrc = self._EMPTY
120 self._gitcookies = self._EMPTY
121
122 @property
123 def netrc(self):
124 if self._netrc is self._EMPTY:
125 self._netrc = self._get_netrc()
126 return self._netrc
127
128 @property
129 def gitcookies(self):
130 if self._gitcookies is self._EMPTY:
131 self._gitcookies = self._get_gitcookies()
132 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000133
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000134 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200135 def get_new_password_url(cls, host):
136 assert not host.startswith('http')
137 # Assume *.googlesource.com pattern.
138 parts = host.split('.')
139 if not parts[0].endswith('-review'):
140 parts[0] += '-review'
141 return 'https://%s/new-password' % ('.'.join(parts))
142
143 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000144 def get_new_password_message(cls, host):
William Hessee9e89e32019-03-03 19:02:32 +0000145 if host is None:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000146 return ('Git host for Gerrit upload is unknown. Check your remote '
William Hessee9e89e32019-03-03 19:02:32 +0000147 'and the branch your branch is tracking. This tool assumes '
148 'that you are using a git server at *.googlesource.com.')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000149 assert not host.startswith('http')
150 # Assume *.googlesource.com pattern.
151 parts = host.split('.')
152 if not parts[0].endswith('-review'):
153 parts[0] += '-review'
154 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100155 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000156
157 @classmethod
158 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000159 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000160 return os.path.expanduser(os.path.join('~', path))
161
162 @classmethod
163 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800164 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000165 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000166 if not os.path.exists(path):
167 return netrc.netrc(os.devnull)
168
169 st = os.stat(path)
170 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000171 print(
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000172 'WARNING: netrc file %s cannot be used because its file '
173 'permissions are insecure. netrc file permissions should be '
Raul Tambre80ee78e2019-05-06 22:41:05 +0000174 '600.' % path, file=sys.stderr)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000175 with open(path) as fd:
176 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800177
178 # Load the '.netrc' file. We strip comments from it because processing them
179 # can trigger a bug in Windows. See crbug.com/664664.
180 content = '\n'.join(l for l in content.splitlines()
181 if l.strip() and not l.strip().startswith('#'))
182 with tempdir() as tdir:
183 netrc_path = os.path.join(tdir, 'netrc')
184 with open(netrc_path, 'w') as fd:
185 fd.write(content)
186 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
187 return cls._get_netrc_from_path(netrc_path)
188
189 @classmethod
190 def _get_netrc_from_path(cls, path):
191 try:
192 return netrc.netrc(path)
193 except IOError:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000194 print('WARNING: Could not read netrc file %s' % path, file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800195 return netrc.netrc(os.devnull)
196 except netrc.NetrcParseError as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000197 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
198 (path, e), file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800199 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000200
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000201 @classmethod
202 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500203 if os.getenv('GIT_COOKIES_PATH'):
204 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800205 try:
206 return subprocess2.check_output(
207 ['git', 'config', '--path', 'http.cookiefile']).strip()
208 except subprocess2.CalledProcessError:
209 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000210
211 @classmethod
212 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000213 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000214 path = cls.get_gitcookies_path()
215 if not os.path.exists(path):
216 return gitcookies
217
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000218 try:
219 f = open(path, 'rb')
220 except IOError:
221 return gitcookies
222
223 with f:
224 for line in f:
225 try:
226 fields = line.strip().split('\t')
227 if line.strip().startswith('#') or len(fields) != 7:
228 continue
229 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
230 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000231 if value.startswith('git-'):
232 login, secret_token = value.split('=', 1)
233 gitcookies[domain] = (login, secret_token)
234 else:
235 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000236 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100237 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000238 return gitcookies
239
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100240 def _get_auth_for_host(self, host):
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000241 for domain, creds in self.gitcookies.items():
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000242 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100243 return (creds[0], None, creds[1])
244 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000245
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100246 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700247 a = self._get_auth_for_host(host)
248 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000249 if a[0]:
250 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
251 else:
252 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000253 return None
254
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100255 def get_auth_email(self, host):
256 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700257 a = self._get_auth_for_host(host)
258 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100259 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700260 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100261 # login typically looks like 'git-xxx.example.com'
262 if not login.startswith('git-') or '.' not in login:
263 return None
264 username, domain = login[len('git-'):].split('.', 1)
265 return '%s@%s' % (username, domain)
266
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100267
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000268# Backwards compatibility just in case somebody imports this outside of
269# depot_tools.
270NetrcAuthenticator = CookiesAuthenticator
271
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000272
273class GceAuthenticator(Authenticator):
274 """Authenticator implementation that uses GCE metadata service for token.
275 """
276
277 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700278 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
279 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000280 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
281
282 _cache_is_gce = None
283 _token_cache = None
284 _token_expiration = None
285
286 @classmethod
287 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500288 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
289 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000290 if cls._cache_is_gce is None:
291 cls._cache_is_gce = cls._test_is_gce()
292 return cls._cache_is_gce
293
294 @classmethod
295 def _test_is_gce(cls):
296 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
297 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100298 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100299 except (socket.error, httplib2.ServerNotFoundError,
300 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000301 # Could not resolve URL.
302 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100303 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000304
305 @staticmethod
306 def _get(url, **kwargs):
307 next_delay_sec = 1
308 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000309 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700310 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100311 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000312 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
313 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100314 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000315
Aaron Gable92e9f382017-12-07 11:47:41 -0800316 # Retry server error status codes.
317 LOGGER.warn('Encountered server error')
318 if TRY_LIMIT - i > 1:
319 LOGGER.info('Will retry in %d seconds (%d more times)...',
320 next_delay_sec, TRY_LIMIT - i - 1)
321 time.sleep(next_delay_sec)
322 next_delay_sec *= 2
323
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000324 @classmethod
325 def _get_token_dict(cls):
326 if cls._token_cache:
327 # If it expires within 25 seconds, refresh.
328 if cls._token_expiration < time.time() - 25:
329 return cls._token_cache
330
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100331 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000332 if resp.status != httplib.OK:
333 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100334 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000335 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
336 return cls._token_cache
337
338 def get_auth_header(self, _host):
339 token_dict = self._get_token_dict()
340 if not token_dict:
341 return None
342 return '%(token_type)s %(access_token)s' % token_dict
343
344
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700345class LuciContextAuthenticator(Authenticator):
346 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
347 """
348
349 @staticmethod
350 def is_luci():
351 return auth.has_luci_context_local_auth()
352
353 def __init__(self):
Edward Lemur5b929a42019-10-21 17:57:39 +0000354 self._authenticator = auth.Authenticator(
355 ' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700356
357 def get_auth_header(self, _host):
Edward Lemur5b929a42019-10-21 17:57:39 +0000358 return 'Bearer %s' % self._authenticator.get_access_token().token
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700359
360
szager@chromium.orgb4696232013-10-16 19:45:35 +0000361def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000362 """Opens an HTTPS connection to a Gerrit service, and sends a request."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000363 headers = headers or {}
364 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000365
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700366 a = Authenticator.get().get_auth_header(bare_host)
367 if a:
368 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000369 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000370 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000371
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800372 url = path
373 if not url.startswith('/'):
374 url = '/' + url
375 if 'Authorization' in headers and not url.startswith('/a/'):
376 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000377
szager@chromium.orgb4696232013-10-16 19:45:35 +0000378 if body:
379 body = json.JSONEncoder().encode(body)
380 headers.setdefault('Content-Type', 'application/json')
381 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000382 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000383 for key, val in headers.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000384 if key == 'Authorization':
385 val = 'HIDDEN'
386 LOGGER.debug('%s: %s' % (key, val))
387 if body:
388 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700389 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000390 # HACK: httplib.Http has no such attribute; we store req_host here for later
391 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000392 conn.req_host = host
393 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100394 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000395 'method': reqtype,
396 'headers': headers,
397 'body': body,
398 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000399 return conn
400
401
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700402def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000403 """Reads an HTTP response from a connection into a string buffer.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000404
405 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100406 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700407 accept_statuses: Treat any of these statuses as success. Default: [200]
408 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000409 Returns: A string buffer containing the connection's reply.
410 """
Steve Kobes56117722018-09-13 18:18:35 +0000411 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000412 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000413 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100414 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000415
Edward Lemur5a9ff432018-10-30 19:00:22 +0000416 response_time = time.time() - before_response
417 metrics.collector.add_repeated(
418 'http_requests',
419 metrics_utils.extract_http_metrics(
420 conn.req_params['uri'], conn.req_params['method'], response.status,
421 response_time))
422
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000423 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100424 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000425 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
426 www_authenticate):
427 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
428 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700429 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000430 'has credentials for %s' % host)
431 raise GerritAuthenticationError(response.status, reason)
432
szager@chromium.orgb4696232013-10-16 19:45:35 +0000433 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000434 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700435 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000436 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700437 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100438 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100439 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700440 # If 404 was in accept_statuses, then it's expected that the file might
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000441 # not exist, so don't return the gitiles error page because that's not
442 # the "content" that was actually requested.
Michael Mossb40a4512017-10-10 11:07:17 -0700443 if response.status == 404:
444 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000445 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000446 # A status >=500 is assumed to be a possible transient error; retry.
447 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
448 LOGGER.warn('A transient error occurred while querying %s:\n'
449 '%s %s %s\n'
450 '%s %d %s',
451 conn.req_host, conn.req_params['method'],
452 conn.req_params['uri'],
453 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000454 if response.status == 404:
455 # TODO(crbug/881860): remove this hack.
456 # HACK: try different Gerrit mirror as a workaround for potentially
457 # out-of-date mirror hit through default routing.
458 if conn.req_host == 'chromium-review.googlesource.com':
459 conn.req_params['uri'] = _UseGerritMirror(
460 conn.req_params['uri'], 'chromium-review.googlesource.com')
461 # And don't increase sleep_time in this case, since we suspect we've
462 # just asked wrong git mirror before.
463 sleep_time /= 2.0
464
szager@chromium.orgb4696232013-10-16 19:45:35 +0000465 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800466 LOGGER.info('Will retry in %d seconds (%d more times)...',
467 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000468 time.sleep(sleep_time)
469 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000470 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700471 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200472 if response.status in (401, 403):
473 print('Your Gerrit credentials might be misconfigured. Try: \n'
474 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100475 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000476 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100477 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000478
479
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700480def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000481 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700482 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000483 # The first line of the response should always be: )]}'
484 s = fh.readline()
485 if s and s.rstrip() != ")]}'":
486 raise GerritError(200, 'Unexpected json output: %s' % s)
487 s = fh.read()
488 if not s:
489 return None
490 return json.loads(s)
491
492
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200493def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100494 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000495 """
496 Queries a gerrit-on-borg server for changes matching query terms.
497
498 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200499 params: A list of key:value pairs for search parameters, as documented
500 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
501 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000502 first_param: A change identifier
503 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100504 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000505 o_params: A list of additional output specifiers, as documented here:
506 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000507
szager@chromium.orgb4696232013-10-16 19:45:35 +0000508 Returns:
509 A list of json-decoded query results.
510 """
511 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200512 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000513 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200514 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100515 if start:
516 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000517 if limit:
518 path = '%s&n=%d' % (path, limit)
519 if o_params:
520 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700521 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000522
523
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200524def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100525 o_params=None, start=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000526 """Queries a gerrit-on-borg server for all the changes matching the query
527 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000528
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100529 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000530 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000532 A single query to gerrit-on-borg is limited on the number of results by the
533 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100534 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000535
536 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200537 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000538 limit: Maximum number of requested changes per query.
539 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100540 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000541
542 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100543 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000544 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100545 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000546
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100547 def at_most_once(cls):
548 for cl in cls:
549 if cl['_number'] not in already_returned:
550 already_returned.add(cl['_number'])
551 yield cl
552
553 start = start or 0
554 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000555 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100556
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000557 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100558 # This will fetch changes[start..start+limit] sorted by most recently
559 # updated. Since the rank of any change in this list can be changed any time
560 # (say user posting comment), subsequent calls may overalp like this:
561 # > initial order ABCDEFGH
562 # query[0..3] => ABC
563 # > E get's updated. New order: EABCDFGH
564 # query[3..6] => CDF # C is a dup
565 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200566 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100567 cur_start)
568 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000569 yield cl
570
571 more_changes = [cl for cl in page if '_more_changes' in cl]
572 if len(more_changes) > 1:
573 raise GerritError(
574 200,
575 'Received %d changes with a _more_changes attribute set but should '
576 'receive at most one.' % len(more_changes))
577 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100578 cur_start += len(page)
579
580 # If we paged through, query again the first page which in most circumstances
581 # will fetch all changes that were modified while this function was run.
582 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200583 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100584 for cl in at_most_once(page):
585 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000586
587
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200588def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100589 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000590 """Initiate a query composed of multiple sets of query parameters."""
591 if not change_list:
592 raise RuntimeError(
593 "MultiQueryChanges requires a list of change numbers/id's")
594 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200595 if params:
596 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000597 if limit:
598 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100599 if start:
600 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000601 if o_params:
602 q.extend(['o=%s' % p for p in o_params])
603 path = 'changes/?%s' % '&'.join(q)
604 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700605 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000606 except GerritError as e:
607 msg = '%s:\n%s' % (e.message, path)
608 raise GerritError(e.http_status, msg)
609 return result
610
611
612def GetGerritFetchUrl(host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000613 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000614 return '%s://%s/' % (GERRIT_PROTOCOL, host)
615
616
Edward Lemur687ca902018-12-05 02:30:30 +0000617def GetCodeReviewTbrScore(host, project):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000618 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000619 """
620 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
621 project = ReadHttpJsonResponse(conn)
622 if ('labels' not in project
623 or 'Code-Review' not in project['labels']
624 or 'values' not in project['labels']['Code-Review']):
625 return 1
626 return max([int(x) for x in project['labels']['Code-Review']['values']])
627
628
szager@chromium.orgb4696232013-10-16 19:45:35 +0000629def GetChangePageUrl(host, change_number):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000630 """Given a Gerrit host name and change number, returns change page URL."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000631 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
632
633
634def GetChangeUrl(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000635 """Given a Gerrit host name and change ID, returns a URL for the change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000636 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
637
638
639def GetChange(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000640 """Queries a Gerrit server for information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000641 path = 'changes/%s' % change
642 return ReadHttpJsonResponse(CreateHttpConn(host, path))
643
644
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700645def GetChangeDetail(host, change, o_params=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000646 """Queries a Gerrit server for extended information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000647 path = 'changes/%s/detail' % change
648 if o_params:
649 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700650 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000651
652
agable32978d92016-11-01 12:55:02 -0700653def GetChangeCommit(host, change, revision='current'):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000654 """Query a Gerrit server for a revision associated with a change."""
agable32978d92016-11-01 12:55:02 -0700655 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
656 return ReadHttpJsonResponse(CreateHttpConn(host, path))
657
658
szager@chromium.orgb4696232013-10-16 19:45:35 +0000659def GetChangeCurrentRevision(host, change):
660 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200661 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000662
663
664def GetChangeRevisions(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000665 """Gets information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200666 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000667
668
669def GetChangeReview(host, change, revision=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000670 """Gets the current review information for a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000671 if not revision:
672 jmsg = GetChangeRevisions(host, change)
673 if not jmsg:
674 return None
675 elif len(jmsg) > 1:
676 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
677 revision = jmsg[0]['current_revision']
678 path = 'changes/%s/revisions/%s/review'
679 return ReadHttpJsonResponse(CreateHttpConn(host, path))
680
681
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700682def GetChangeComments(host, change):
683 """Get the line- and file-level comments on a change."""
684 path = 'changes/%s/comments' % change
685 return ReadHttpJsonResponse(CreateHttpConn(host, path))
686
687
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000688def GetChangeRobotComments(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000689 """Gets the line- and file-level robot comments on a change."""
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000690 path = 'changes/%s/robotcomments' % change
691 return ReadHttpJsonResponse(CreateHttpConn(host, path))
692
693
szager@chromium.orgb4696232013-10-16 19:45:35 +0000694def AbandonChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000695 """Abandons a Gerrit change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000696 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000697 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000698 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700699 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000700
701
702def RestoreChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000703 """Restores a previously abandoned change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000704 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000705 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000706 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700707 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000708
709
710def SubmitChange(host, change, wait_for_merge=True):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000711 """Submits a Gerrit change via Gerrit."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000712 path = 'changes/%s/submit' % change
713 body = {'wait_for_merge': wait_for_merge}
714 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700715 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000716
717
dsansomee2d6fd92016-09-08 00:10:47 -0700718def HasPendingChangeEdit(host, change):
719 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
720 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700721 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700722 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700723 # 204 No Content means no pending change.
724 if e.http_status == 204:
725 return False
726 raise
727 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700728
729
730def DeletePendingChangeEdit(host, change):
731 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000732 # On success, Gerrit returns status 204; if the edit was already deleted it
Aaron Gable19ee16c2017-04-18 11:56:35 -0700733 # returns 404. Anything else is an error.
734 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700735
736
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100737def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000738 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700739 assert notify in ('ALL', 'NONE')
740 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700741 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700742 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000743 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700744 ReadHttpResponse(conn, accept_statuses=[200, 204])
745 except GerritError as e:
746 raise GerritError(
747 e.http_status,
748 'Received unexpected http status while editing message '
749 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000750
751
szager@chromium.orgb4696232013-10-16 19:45:35 +0000752def GetReviewers(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000753 """Gets information about all reviewers attached to a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000754 path = 'changes/%s/reviewers' % change
755 return ReadHttpJsonResponse(CreateHttpConn(host, path))
756
757
758def GetReview(host, change, revision):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000759 """Gets review information about a specific revision of a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000760 path = 'changes/%s/revisions/%s/review' % (change, revision)
761 return ReadHttpJsonResponse(CreateHttpConn(host, path))
762
763
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700764def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
765 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000766 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700767 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800768 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200769 if not change:
770 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700771 reviewers = frozenset(reviewers or [])
772 ccs = frozenset(ccs or [])
773 path = 'changes/%s/revisions/current/review' % change
774
775 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800776 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700777 'reviewers': [],
778 'notify': 'ALL' if notify else 'NONE',
779 }
780 for r in sorted(reviewers | ccs):
781 state = 'REVIEWER' if r in reviewers else 'CC'
782 body['reviewers'].append({
783 'reviewer': r,
784 'state': state,
785 'notify': 'NONE', # We handled `notify` argument above.
Raul Tambre80ee78e2019-05-06 22:41:05 +0000786 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700787
788 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
789 # Gerrit will return 400 if one or more of the requested reviewers are
790 # unprocessable. We read the response object to see which were rejected,
791 # warn about them, and retry with the remainder.
792 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
793
794 errored = set()
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000795 for result in resp.get('reviewers', {}).values():
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700796 r = result.get('input')
797 state = 'REVIEWER' if r in reviewers else 'CC'
798 if result.get('error'):
799 errored.add(r)
800 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
801 if errored:
802 # Try again, adding only those that didn't fail, and only accepting 200.
803 AddReviewers(host, change, reviewers=(reviewers-errored),
804 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000805
806
807def RemoveReviewers(host, change, remove=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000808 """Removes reviewers from a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000809 if not remove:
810 return
811 if isinstance(remove, basestring):
812 remove = (remove,)
813 for r in remove:
814 path = 'changes/%s/reviewers/%s' % (change, r)
815 conn = CreateHttpConn(host, path, reqtype='DELETE')
816 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700817 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000818 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000819 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700820 e.http_status,
821 'Received unexpected http status while deleting reviewer "%s" '
822 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000823
824
Aaron Gable636b13f2017-07-14 10:42:48 -0700825def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000826 """Sets labels and/or adds a message to a code review."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000827 if not msg and not labels:
828 return
829 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800830 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000831 if msg:
832 body['message'] = msg
833 if labels:
834 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700835 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700836 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700837 if ready:
838 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000839 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
840 response = ReadHttpJsonResponse(conn)
841 if labels:
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000842 for key, val in labels.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000843 if ('labels' not in response or key not in response['labels'] or
844 int(response['labels'][key] != int(val))):
845 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
846 key, change))
847
848
849def ResetReviewLabels(host, change, label, value='0', message=None,
850 notify=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000851 """Resets the value of a given label for all reviewers on a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000852 # This is tricky, because we want to work on the "current revision", but
853 # there's always the risk that "current revision" will change in between
854 # API calls. So, we check "current revision" at the beginning and end; if
855 # it has changed, raise an exception.
856 jmsg = GetChangeCurrentRevision(host, change)
857 if not jmsg:
858 raise GerritError(
859 200, 'Could not get review information for change "%s"' % change)
860 value = str(value)
861 revision = jmsg[0]['current_revision']
862 path = 'changes/%s/revisions/%s/review' % (change, revision)
863 message = message or (
864 '%s label set to %s programmatically.' % (label, value))
865 jmsg = GetReview(host, change, revision)
866 if not jmsg:
867 raise GerritError(200, 'Could not get review information for revison %s '
868 'of change %s' % (revision, change))
869 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
870 if str(review.get('value', value)) != value:
871 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800872 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000873 'message': message,
874 'labels': {label: value},
875 'on_behalf_of': review['_account_id'],
876 }
877 if notify:
878 body['notify'] = notify
879 conn = CreateHttpConn(
880 host, path, reqtype='POST', body=body)
881 response = ReadHttpJsonResponse(conn)
882 if str(response['labels'][label]) != value:
883 username = review.get('email', jmsg.get('name', ''))
884 raise GerritError(200, 'Unable to set %s label for user "%s"'
885 ' on change %s.' % (label, username, change))
886 jmsg = GetChangeCurrentRevision(host, change)
887 if not jmsg:
888 raise GerritError(
889 200, 'Could not get review information for change "%s"' % change)
890 elif jmsg[0]['current_revision'] != revision:
891 raise GerritError(200, 'While resetting labels on change "%s", '
892 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800893
894
dimu833c94c2017-01-18 17:36:15 -0800895def CreateGerritBranch(host, project, branch, commit):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000896 """Creates a new branch from given project and commit
897
dimu833c94c2017-01-18 17:36:15 -0800898 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
899
900 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000901 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -0800902 """
903 path = 'projects/%s/branches/%s' % (project, branch)
904 body = {'revision': commit}
905 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700906 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800907 if response:
908 return response
909 raise GerritError(200, 'Unable to create gerrit branch')
910
911
912def GetGerritBranch(host, project, branch):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000913 """Gets a branch from given project and commit.
914
915 See:
dimu833c94c2017-01-18 17:36:15 -0800916 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
917
918 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000919 A JSON object with 'revision' key.
dimu833c94c2017-01-18 17:36:15 -0800920 """
921 path = 'projects/%s/branches/%s' % (project, branch)
922 conn = CreateHttpConn(host, path, reqtype='GET')
923 response = ReadHttpJsonResponse(conn)
924 if response:
925 return response
926 raise GerritError(200, 'Unable to get gerrit branch')
927
928
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100929def GetAccountDetails(host, account_id='self'):
930 """Returns details of the account.
931
932 If account_id is not given, uses magic value 'self' which corresponds to
933 whichever account user is authenticating as.
934
935 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000936 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000937
938 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100939 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100940 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000941 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
942
943
944def ValidAccounts(host, accounts, max_threads=10):
945 """Returns a mapping from valid account to its details.
946
947 Invalid accounts, either not existing or without unique match,
948 are not present as returned dictionary keys.
949 """
950 assert not isinstance(accounts, basestring), type(accounts)
951 accounts = list(set(accounts))
952 if not accounts:
953 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000954
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000955 def get_one(account):
956 try:
957 return account, GetAccountDetails(host, account)
958 except GerritError:
959 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000960
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000961 valid = {}
962 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
963 for account, details in pool.map(get_one, accounts):
964 if account and details:
965 valid[account] = details
966 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100967
968
Nick Carter8692b182017-11-06 16:30:38 -0800969def PercentEncodeForGitRef(original):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000970 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -0800971
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000972 The encoding used is based on but stricter than URL encoding (Section 2.1 of
973 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
974 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -0800975
976 For more information, see the Gerrit docs here:
977
978 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
979 """
980 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
981 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
982
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000983 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
Nick Carter8692b182017-11-06 16:30:38 -0800984 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
985 return encoded.replace(' ', '_')
986
987
Dan Jacques8d11e482016-11-15 14:25:56 -0800988@contextlib.contextmanager
989def tempdir():
990 tdir = None
991 try:
992 tdir = tempfile.mkdtemp(suffix='gerrit_util')
993 yield tdir
994 finally:
995 if tdir:
996 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000997
998
999def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +00001000 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001001 this module API.
1002
1003 Such format is allows for more efficient Gerrit routing of HTTP requests,
1004 comparing to specifying just change_number.
1005 """
1006 assert int(change_number)
1007 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001008
1009
1010# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001011_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001012assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1013
1014
1015def _UseGerritMirror(url, host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001016 """Returns a new URL which uses randomly selected mirror for a Gerrit host.
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001017
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001018 The URL's host should be for a given host or a result of prior call to this
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001019 function.
1020
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001021 Assumes that the URL has a single occurence of the host substring.
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001022 """
1023 assert host in url
1024 suffix = '-mirror-' + host
1025 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1026 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1027 st = url.find(suffix)
1028 if st == -1:
1029 actual_host = host
1030 else:
1031 # Already uses some mirror.
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001032 assert st >= prefix_len, (url, host, st, prefix_len)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001033 prefixes.remove(url[st-prefix_len:st])
1034 actual_host = url[st-prefix_len:st+len(suffix)]
1035 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)