blob: a8a1c3bd5884d45d0642bfb366846e68ff48a57d [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
Edward Lemur202c5592019-10-21 22:44:52 +000015import httplib2
szager@chromium.orgb4696232013-10-16 19:45:35 +000016import json
17import logging
18import netrc
19import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000020import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000021import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000022import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000023import stat
24import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080025import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000026import time
27import urllib
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000028from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000029
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070030import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080031import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000032import metrics
33import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080034import subprocess2
szager@chromium.orgf202a252014-05-27 18:55:52 +000035
Edward Lemura834f392019-10-22 22:23:00 +000036if sys.version_info.major == 2:
37 import cookielib
38 import httplib
39 import urlparse
40 from cStringIO import StringIO
41else:
42 import http.cookiejar as cookielib
43 import http.client as httplib
44 import urllib.parse as urlparse
45 from io import StringIO
46
szager@chromium.orgb4696232013-10-16 19:45:35 +000047LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000048# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
49# total tries, the sleep time between the first and last tries will be 94.5 sec.
50# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
51TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000052
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000053
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +000054# Controls the transport protocol used to communicate with Gerrit.
szager@chromium.orgb4696232013-10-16 19:45:35 +000055# This is parameterized primarily to enable GerritTestCase.
56GERRIT_PROTOCOL = 'https'
57
58
59class GerritError(Exception):
60 """Exception class for errors commuicating with the gerrit-on-borg service."""
61 def __init__(self, http_status, *args, **kwargs):
62 super(GerritError, self).__init__(*args, **kwargs)
63 self.http_status = http_status
64 self.message = '(%d) %s' % (self.http_status, self.message)
65
66
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000067class GerritAuthenticationError(GerritError):
68 """Exception class for authentication errors during Gerrit communication."""
69
70
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020071def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000072 """Encodes query parameters in the key:val[+key:val...] format specified here:
73
74 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
75 """
76 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020077 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000078 return '+'.join(q)
79
80
Aaron Gabled2db5a22017-03-24 14:14:15 -070081def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000082 if protocol is None:
83 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010084 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070085 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000086 else:
87 raise RuntimeError(
88 "Don't know how to work with protocol '%s'" % protocol)
89
90
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000091class Authenticator(object):
92 """Base authenticator class for authenticator implementations to subclass."""
93
94 def get_auth_header(self, host):
95 raise NotImplementedError()
96
97 @staticmethod
98 def get():
99 """Returns: (Authenticator) The identified Authenticator to use.
100
101 Probes the local system and its environment and identifies the
102 Authenticator instance to use.
103 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700104 # LUCI Context takes priority since it's normally present only on bots,
105 # which then must use it.
106 if LuciContextAuthenticator.is_luci():
107 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000108 if GceAuthenticator.is_gce():
109 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000110 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000111
112
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000113class CookiesAuthenticator(Authenticator):
114 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
115
116 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000117 """
118
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000119 _EMPTY = object()
120
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000121 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000122 # Credentials will be loaded lazily on first use. This ensures Authenticator
123 # get() can always construct an authenticator, even if something is broken.
124 # This allows 'creds-check' to proceed to actually checking creds later,
125 # rigorously (instead of blowing up with a cryptic error if they are wrong).
126 self._netrc = self._EMPTY
127 self._gitcookies = self._EMPTY
128
129 @property
130 def netrc(self):
131 if self._netrc is self._EMPTY:
132 self._netrc = self._get_netrc()
133 return self._netrc
134
135 @property
136 def gitcookies(self):
137 if self._gitcookies is self._EMPTY:
138 self._gitcookies = self._get_gitcookies()
139 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000140
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000141 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200142 def get_new_password_url(cls, host):
143 assert not host.startswith('http')
144 # Assume *.googlesource.com pattern.
145 parts = host.split('.')
146 if not parts[0].endswith('-review'):
147 parts[0] += '-review'
148 return 'https://%s/new-password' % ('.'.join(parts))
149
150 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000151 def get_new_password_message(cls, host):
William Hessee9e89e32019-03-03 19:02:32 +0000152 if host is None:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000153 return ('Git host for Gerrit upload is unknown. Check your remote '
William Hessee9e89e32019-03-03 19:02:32 +0000154 'and the branch your branch is tracking. This tool assumes '
155 'that you are using a git server at *.googlesource.com.')
Edward Lemur67fccdf2019-10-22 22:17:10 +0000156 url = cls.get_new_password_url(host)
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100157 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000158
159 @classmethod
160 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000161 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000162 return os.path.expanduser(os.path.join('~', path))
163
164 @classmethod
165 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800166 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000167 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000168 if not os.path.exists(path):
169 return netrc.netrc(os.devnull)
170
171 st = os.stat(path)
172 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000173 print(
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000174 'WARNING: netrc file %s cannot be used because its file '
175 'permissions are insecure. netrc file permissions should be '
Raul Tambre80ee78e2019-05-06 22:41:05 +0000176 '600.' % path, file=sys.stderr)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000177 with open(path) as fd:
178 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800179
180 # Load the '.netrc' file. We strip comments from it because processing them
181 # can trigger a bug in Windows. See crbug.com/664664.
182 content = '\n'.join(l for l in content.splitlines()
183 if l.strip() and not l.strip().startswith('#'))
184 with tempdir() as tdir:
185 netrc_path = os.path.join(tdir, 'netrc')
186 with open(netrc_path, 'w') as fd:
187 fd.write(content)
188 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
189 return cls._get_netrc_from_path(netrc_path)
190
191 @classmethod
192 def _get_netrc_from_path(cls, path):
193 try:
194 return netrc.netrc(path)
195 except IOError:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000196 print('WARNING: Could not read netrc file %s' % path, file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800197 return netrc.netrc(os.devnull)
198 except netrc.NetrcParseError as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000199 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
200 (path, e), file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800201 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000202
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000203 @classmethod
204 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500205 if os.getenv('GIT_COOKIES_PATH'):
206 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800207 try:
208 return subprocess2.check_output(
209 ['git', 'config', '--path', 'http.cookiefile']).strip()
210 except subprocess2.CalledProcessError:
211 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000212
213 @classmethod
214 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000215 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000216 path = cls.get_gitcookies_path()
217 if not os.path.exists(path):
218 return gitcookies
219
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000220 try:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000221 f = gclient_utils.FileRead(path, 'rb').splitlines()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000222 except IOError:
223 return gitcookies
224
Edward Lemur67fccdf2019-10-22 22:17:10 +0000225 for line in f:
226 try:
227 fields = line.strip().split('\t')
228 if line.strip().startswith('#') or len(fields) != 7:
229 continue
230 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
231 if xpath == '/' and key == 'o':
232 if value.startswith('git-'):
233 login, secret_token = value.split('=', 1)
234 gitcookies[domain] = (login, secret_token)
235 else:
236 gitcookies[domain] = ('', value)
237 except (IndexError, ValueError, TypeError) as exc:
238 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000239 return gitcookies
240
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100241 def _get_auth_for_host(self, host):
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000242 for domain, creds in self.gitcookies.items():
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000243 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100244 return (creds[0], None, creds[1])
245 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000246
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100247 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700248 a = self._get_auth_for_host(host)
249 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000250 if a[0]:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000251 secret = base64.b64encode(('%s:%s' % (a[0], a[2])).encode('utf-8'))
252 return 'Basic %s' % secret.decode('utf-8')
Eric Boren2fb63102018-10-05 13:05:03 +0000253 else:
254 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000255 return None
256
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100257 def get_auth_email(self, host):
258 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700259 a = self._get_auth_for_host(host)
260 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100261 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700262 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100263 # login typically looks like 'git-xxx.example.com'
264 if not login.startswith('git-') or '.' not in login:
265 return None
266 username, domain = login[len('git-'):].split('.', 1)
267 return '%s@%s' % (username, domain)
268
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100269
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000270# Backwards compatibility just in case somebody imports this outside of
271# depot_tools.
272NetrcAuthenticator = CookiesAuthenticator
273
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000274
275class GceAuthenticator(Authenticator):
276 """Authenticator implementation that uses GCE metadata service for token.
277 """
278
279 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700280 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
281 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000282 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
283
284 _cache_is_gce = None
285 _token_cache = None
286 _token_expiration = None
287
288 @classmethod
289 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500290 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
291 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000292 if cls._cache_is_gce is None:
293 cls._cache_is_gce = cls._test_is_gce()
294 return cls._cache_is_gce
295
296 @classmethod
297 def _test_is_gce(cls):
298 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
299 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100300 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100301 except (socket.error, httplib2.ServerNotFoundError,
302 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 # Could not resolve URL.
304 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100305 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000306
307 @staticmethod
308 def _get(url, **kwargs):
309 next_delay_sec = 1
310 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000311 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700312 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100313 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000314 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
315 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100316 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000317
Aaron Gable92e9f382017-12-07 11:47:41 -0800318 # Retry server error status codes.
319 LOGGER.warn('Encountered server error')
320 if TRY_LIMIT - i > 1:
321 LOGGER.info('Will retry in %d seconds (%d more times)...',
322 next_delay_sec, TRY_LIMIT - i - 1)
323 time.sleep(next_delay_sec)
324 next_delay_sec *= 2
325
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000326 @classmethod
327 def _get_token_dict(cls):
328 if cls._token_cache:
329 # If it expires within 25 seconds, refresh.
330 if cls._token_expiration < time.time() - 25:
331 return cls._token_cache
332
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100333 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000334 if resp.status != httplib.OK:
335 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100336 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000337 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
338 return cls._token_cache
339
340 def get_auth_header(self, _host):
341 token_dict = self._get_token_dict()
342 if not token_dict:
343 return None
344 return '%(token_type)s %(access_token)s' % token_dict
345
346
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700347class LuciContextAuthenticator(Authenticator):
348 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
349 """
350
351 @staticmethod
352 def is_luci():
353 return auth.has_luci_context_local_auth()
354
355 def __init__(self):
Edward Lemur5b929a42019-10-21 17:57:39 +0000356 self._authenticator = auth.Authenticator(
357 ' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700358
359 def get_auth_header(self, _host):
Edward Lemur5b929a42019-10-21 17:57:39 +0000360 return 'Bearer %s' % self._authenticator.get_access_token().token
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700361
362
szager@chromium.orgb4696232013-10-16 19:45:35 +0000363def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000364 """Opens an HTTPS connection to a Gerrit service, and sends a request."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000365 headers = headers or {}
366 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000367
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700368 a = Authenticator.get().get_auth_header(bare_host)
369 if a:
370 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000371 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000372 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000373
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800374 url = path
375 if not url.startswith('/'):
376 url = '/' + url
377 if 'Authorization' in headers and not url.startswith('/a/'):
378 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000379
szager@chromium.orgb4696232013-10-16 19:45:35 +0000380 if body:
381 body = json.JSONEncoder().encode(body)
382 headers.setdefault('Content-Type', 'application/json')
383 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000384 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000385 for key, val in headers.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000386 if key == 'Authorization':
387 val = 'HIDDEN'
388 LOGGER.debug('%s: %s' % (key, val))
389 if body:
390 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700391 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000392 # HACK: httplib.Http has no such attribute; we store req_host here for later
393 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000394 conn.req_host = host
395 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100396 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000397 'method': reqtype,
398 'headers': headers,
399 'body': body,
400 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000401 return conn
402
403
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700404def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000405 """Reads an HTTP response from a connection into a string buffer.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000406
407 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100408 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700409 accept_statuses: Treat any of these statuses as success. Default: [200]
410 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000411 Returns: A string buffer containing the connection's reply.
412 """
Steve Kobes56117722018-09-13 18:18:35 +0000413 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000414 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000415 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100416 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000417
Edward Lemur5a9ff432018-10-30 19:00:22 +0000418 response_time = time.time() - before_response
419 metrics.collector.add_repeated(
420 'http_requests',
421 metrics_utils.extract_http_metrics(
422 conn.req_params['uri'], conn.req_params['method'], response.status,
423 response_time))
424
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000425 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100426 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000427 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
428 www_authenticate):
429 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
430 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700431 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000432 'has credentials for %s' % host)
433 raise GerritAuthenticationError(response.status, reason)
434
szager@chromium.orgb4696232013-10-16 19:45:35 +0000435 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000436 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700437 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000438 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700439 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100440 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100441 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700442 # If 404 was in accept_statuses, then it's expected that the file might
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000443 # not exist, so don't return the gitiles error page because that's not
444 # the "content" that was actually requested.
Michael Mossb40a4512017-10-10 11:07:17 -0700445 if response.status == 404:
446 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000447 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000448 # A status >=500 is assumed to be a possible transient error; retry.
449 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
450 LOGGER.warn('A transient error occurred while querying %s:\n'
451 '%s %s %s\n'
452 '%s %d %s',
453 conn.req_host, conn.req_params['method'],
454 conn.req_params['uri'],
455 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000456 if response.status == 404:
457 # TODO(crbug/881860): remove this hack.
458 # HACK: try different Gerrit mirror as a workaround for potentially
459 # out-of-date mirror hit through default routing.
460 if conn.req_host == 'chromium-review.googlesource.com':
461 conn.req_params['uri'] = _UseGerritMirror(
462 conn.req_params['uri'], 'chromium-review.googlesource.com')
463 # And don't increase sleep_time in this case, since we suspect we've
464 # just asked wrong git mirror before.
465 sleep_time /= 2.0
466
szager@chromium.orgb4696232013-10-16 19:45:35 +0000467 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800468 LOGGER.info('Will retry in %d seconds (%d more times)...',
469 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000470 time.sleep(sleep_time)
471 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000472 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700473 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200474 if response.status in (401, 403):
475 print('Your Gerrit credentials might be misconfigured. Try: \n'
476 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100477 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000478 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100479 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000480
481
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700482def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000483 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700484 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000485 # The first line of the response should always be: )]}'
486 s = fh.readline()
487 if s and s.rstrip() != ")]}'":
488 raise GerritError(200, 'Unexpected json output: %s' % s)
489 s = fh.read()
490 if not s:
491 return None
492 return json.loads(s)
493
494
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200495def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100496 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000497 """
498 Queries a gerrit-on-borg server for changes matching query terms.
499
500 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200501 params: A list of key:value pairs for search parameters, as documented
502 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
503 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000504 first_param: A change identifier
505 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100506 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000507 o_params: A list of additional output specifiers, as documented here:
508 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000509
szager@chromium.orgb4696232013-10-16 19:45:35 +0000510 Returns:
511 A list of json-decoded query results.
512 """
513 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200514 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000515 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200516 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100517 if start:
518 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000519 if limit:
520 path = '%s&n=%d' % (path, limit)
521 if o_params:
522 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700523 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000524
525
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200526def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100527 o_params=None, start=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000528 """Queries a gerrit-on-borg server for all the changes matching the query
529 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000530
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000532 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100533
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000534 A single query to gerrit-on-borg is limited on the number of results by the
535 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100536 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000537
538 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200539 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000540 limit: Maximum number of requested changes per query.
541 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100542 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000543
544 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100545 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000546 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100547 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000548
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100549 def at_most_once(cls):
550 for cl in cls:
551 if cl['_number'] not in already_returned:
552 already_returned.add(cl['_number'])
553 yield cl
554
555 start = start or 0
556 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000557 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100558
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000559 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100560 # This will fetch changes[start..start+limit] sorted by most recently
561 # updated. Since the rank of any change in this list can be changed any time
562 # (say user posting comment), subsequent calls may overalp like this:
563 # > initial order ABCDEFGH
564 # query[0..3] => ABC
565 # > E get's updated. New order: EABCDFGH
566 # query[3..6] => CDF # C is a dup
567 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200568 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100569 cur_start)
570 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000571 yield cl
572
573 more_changes = [cl for cl in page if '_more_changes' in cl]
574 if len(more_changes) > 1:
575 raise GerritError(
576 200,
577 'Received %d changes with a _more_changes attribute set but should '
578 'receive at most one.' % len(more_changes))
579 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100580 cur_start += len(page)
581
582 # If we paged through, query again the first page which in most circumstances
583 # will fetch all changes that were modified while this function was run.
584 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200585 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100586 for cl in at_most_once(page):
587 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000588
589
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200590def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100591 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000592 """Initiate a query composed of multiple sets of query parameters."""
593 if not change_list:
594 raise RuntimeError(
595 "MultiQueryChanges requires a list of change numbers/id's")
596 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200597 if params:
598 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000599 if limit:
600 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100601 if start:
602 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000603 if o_params:
604 q.extend(['o=%s' % p for p in o_params])
605 path = 'changes/?%s' % '&'.join(q)
606 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700607 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000608 except GerritError as e:
609 msg = '%s:\n%s' % (e.message, path)
610 raise GerritError(e.http_status, msg)
611 return result
612
613
614def GetGerritFetchUrl(host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000615 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000616 return '%s://%s/' % (GERRIT_PROTOCOL, host)
617
618
Edward Lemur687ca902018-12-05 02:30:30 +0000619def GetCodeReviewTbrScore(host, project):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000620 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000621 """
622 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
623 project = ReadHttpJsonResponse(conn)
624 if ('labels' not in project
625 or 'Code-Review' not in project['labels']
626 or 'values' not in project['labels']['Code-Review']):
627 return 1
628 return max([int(x) for x in project['labels']['Code-Review']['values']])
629
630
szager@chromium.orgb4696232013-10-16 19:45:35 +0000631def GetChangePageUrl(host, change_number):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000632 """Given a Gerrit host name and change number, returns change page URL."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000633 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
634
635
636def GetChangeUrl(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000637 """Given a Gerrit host name and change ID, returns a URL for the change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000638 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
639
640
641def GetChange(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000642 """Queries a Gerrit server for information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000643 path = 'changes/%s' % change
644 return ReadHttpJsonResponse(CreateHttpConn(host, path))
645
646
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700647def GetChangeDetail(host, change, o_params=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000648 """Queries a Gerrit server for extended information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000649 path = 'changes/%s/detail' % change
650 if o_params:
651 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700652 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000653
654
agable32978d92016-11-01 12:55:02 -0700655def GetChangeCommit(host, change, revision='current'):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000656 """Query a Gerrit server for a revision associated with a change."""
agable32978d92016-11-01 12:55:02 -0700657 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
658 return ReadHttpJsonResponse(CreateHttpConn(host, path))
659
660
szager@chromium.orgb4696232013-10-16 19:45:35 +0000661def GetChangeCurrentRevision(host, change):
662 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200663 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664
665
666def GetChangeRevisions(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000667 """Gets information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200668 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000669
670
671def GetChangeReview(host, change, revision=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000672 """Gets the current review information for a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000673 if not revision:
674 jmsg = GetChangeRevisions(host, change)
675 if not jmsg:
676 return None
677 elif len(jmsg) > 1:
678 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
679 revision = jmsg[0]['current_revision']
680 path = 'changes/%s/revisions/%s/review'
681 return ReadHttpJsonResponse(CreateHttpConn(host, path))
682
683
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700684def GetChangeComments(host, change):
685 """Get the line- and file-level comments on a change."""
686 path = 'changes/%s/comments' % change
687 return ReadHttpJsonResponse(CreateHttpConn(host, path))
688
689
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000690def GetChangeRobotComments(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000691 """Gets the line- and file-level robot comments on a change."""
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000692 path = 'changes/%s/robotcomments' % change
693 return ReadHttpJsonResponse(CreateHttpConn(host, path))
694
695
szager@chromium.orgb4696232013-10-16 19:45:35 +0000696def AbandonChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000697 """Abandons a Gerrit change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000698 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000699 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000700 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700701 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000702
703
704def RestoreChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000705 """Restores a previously abandoned change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000706 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000707 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000708 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700709 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000710
711
712def SubmitChange(host, change, wait_for_merge=True):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000713 """Submits a Gerrit change via Gerrit."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000714 path = 'changes/%s/submit' % change
715 body = {'wait_for_merge': wait_for_merge}
716 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700717 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000718
719
dsansomee2d6fd92016-09-08 00:10:47 -0700720def HasPendingChangeEdit(host, change):
721 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
722 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700723 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700724 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700725 # 204 No Content means no pending change.
726 if e.http_status == 204:
727 return False
728 raise
729 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700730
731
732def DeletePendingChangeEdit(host, change):
733 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000734 # On success, Gerrit returns status 204; if the edit was already deleted it
Aaron Gable19ee16c2017-04-18 11:56:35 -0700735 # returns 404. Anything else is an error.
736 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700737
738
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100739def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000740 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700741 assert notify in ('ALL', 'NONE')
742 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700743 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700744 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000745 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700746 ReadHttpResponse(conn, accept_statuses=[200, 204])
747 except GerritError as e:
748 raise GerritError(
749 e.http_status,
750 'Received unexpected http status while editing message '
751 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000752
753
szager@chromium.orgb4696232013-10-16 19:45:35 +0000754def GetReviewers(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000755 """Gets information about all reviewers attached to a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000756 path = 'changes/%s/reviewers' % change
757 return ReadHttpJsonResponse(CreateHttpConn(host, path))
758
759
760def GetReview(host, change, revision):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000761 """Gets review information about a specific revision of a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000762 path = 'changes/%s/revisions/%s/review' % (change, revision)
763 return ReadHttpJsonResponse(CreateHttpConn(host, path))
764
765
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700766def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
767 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000768 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700769 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800770 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200771 if not change:
772 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700773 reviewers = frozenset(reviewers or [])
774 ccs = frozenset(ccs or [])
775 path = 'changes/%s/revisions/current/review' % change
776
777 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800778 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700779 'reviewers': [],
780 'notify': 'ALL' if notify else 'NONE',
781 }
782 for r in sorted(reviewers | ccs):
783 state = 'REVIEWER' if r in reviewers else 'CC'
784 body['reviewers'].append({
785 'reviewer': r,
786 'state': state,
787 'notify': 'NONE', # We handled `notify` argument above.
Raul Tambre80ee78e2019-05-06 22:41:05 +0000788 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700789
790 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
791 # Gerrit will return 400 if one or more of the requested reviewers are
792 # unprocessable. We read the response object to see which were rejected,
793 # warn about them, and retry with the remainder.
794 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
795
796 errored = set()
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000797 for result in resp.get('reviewers', {}).values():
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700798 r = result.get('input')
799 state = 'REVIEWER' if r in reviewers else 'CC'
800 if result.get('error'):
801 errored.add(r)
802 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
803 if errored:
804 # Try again, adding only those that didn't fail, and only accepting 200.
805 AddReviewers(host, change, reviewers=(reviewers-errored),
806 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000807
808
809def RemoveReviewers(host, change, remove=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000810 """Removes reviewers from a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000811 if not remove:
812 return
813 if isinstance(remove, basestring):
814 remove = (remove,)
815 for r in remove:
816 path = 'changes/%s/reviewers/%s' % (change, r)
817 conn = CreateHttpConn(host, path, reqtype='DELETE')
818 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700819 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000820 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000821 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700822 e.http_status,
823 'Received unexpected http status while deleting reviewer "%s" '
824 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000825
826
Aaron Gable636b13f2017-07-14 10:42:48 -0700827def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000828 """Sets labels and/or adds a message to a code review."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000829 if not msg and not labels:
830 return
831 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800832 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000833 if msg:
834 body['message'] = msg
835 if labels:
836 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700837 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700838 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700839 if ready:
840 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000841 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
842 response = ReadHttpJsonResponse(conn)
843 if labels:
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000844 for key, val in labels.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000845 if ('labels' not in response or key not in response['labels'] or
846 int(response['labels'][key] != int(val))):
847 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
848 key, change))
849
850
851def ResetReviewLabels(host, change, label, value='0', message=None,
852 notify=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000853 """Resets the value of a given label for all reviewers on a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000854 # This is tricky, because we want to work on the "current revision", but
855 # there's always the risk that "current revision" will change in between
856 # API calls. So, we check "current revision" at the beginning and end; if
857 # it has changed, raise an exception.
858 jmsg = GetChangeCurrentRevision(host, change)
859 if not jmsg:
860 raise GerritError(
861 200, 'Could not get review information for change "%s"' % change)
862 value = str(value)
863 revision = jmsg[0]['current_revision']
864 path = 'changes/%s/revisions/%s/review' % (change, revision)
865 message = message or (
866 '%s label set to %s programmatically.' % (label, value))
867 jmsg = GetReview(host, change, revision)
868 if not jmsg:
869 raise GerritError(200, 'Could not get review information for revison %s '
870 'of change %s' % (revision, change))
871 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
872 if str(review.get('value', value)) != value:
873 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800874 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000875 'message': message,
876 'labels': {label: value},
877 'on_behalf_of': review['_account_id'],
878 }
879 if notify:
880 body['notify'] = notify
881 conn = CreateHttpConn(
882 host, path, reqtype='POST', body=body)
883 response = ReadHttpJsonResponse(conn)
884 if str(response['labels'][label]) != value:
885 username = review.get('email', jmsg.get('name', ''))
886 raise GerritError(200, 'Unable to set %s label for user "%s"'
887 ' on change %s.' % (label, username, change))
888 jmsg = GetChangeCurrentRevision(host, change)
889 if not jmsg:
890 raise GerritError(
891 200, 'Could not get review information for change "%s"' % change)
892 elif jmsg[0]['current_revision'] != revision:
893 raise GerritError(200, 'While resetting labels on change "%s", '
894 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800895
896
dimu833c94c2017-01-18 17:36:15 -0800897def CreateGerritBranch(host, project, branch, commit):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000898 """Creates a new branch from given project and commit
899
dimu833c94c2017-01-18 17:36:15 -0800900 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
901
902 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000903 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -0800904 """
905 path = 'projects/%s/branches/%s' % (project, branch)
906 body = {'revision': commit}
907 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700908 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800909 if response:
910 return response
911 raise GerritError(200, 'Unable to create gerrit branch')
912
913
914def GetGerritBranch(host, project, branch):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000915 """Gets a branch from given project and commit.
916
917 See:
dimu833c94c2017-01-18 17:36:15 -0800918 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
919
920 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000921 A JSON object with 'revision' key.
dimu833c94c2017-01-18 17:36:15 -0800922 """
923 path = 'projects/%s/branches/%s' % (project, branch)
924 conn = CreateHttpConn(host, path, reqtype='GET')
925 response = ReadHttpJsonResponse(conn)
926 if response:
927 return response
928 raise GerritError(200, 'Unable to get gerrit branch')
929
930
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100931def GetAccountDetails(host, account_id='self'):
932 """Returns details of the account.
933
934 If account_id is not given, uses magic value 'self' which corresponds to
935 whichever account user is authenticating as.
936
937 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000938 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000939
940 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100941 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100942 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000943 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
944
945
946def ValidAccounts(host, accounts, max_threads=10):
947 """Returns a mapping from valid account to its details.
948
949 Invalid accounts, either not existing or without unique match,
950 are not present as returned dictionary keys.
951 """
952 assert not isinstance(accounts, basestring), type(accounts)
953 accounts = list(set(accounts))
954 if not accounts:
955 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000956
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000957 def get_one(account):
958 try:
959 return account, GetAccountDetails(host, account)
960 except GerritError:
961 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000962
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000963 valid = {}
964 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
965 for account, details in pool.map(get_one, accounts):
966 if account and details:
967 valid[account] = details
968 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100969
970
Nick Carter8692b182017-11-06 16:30:38 -0800971def PercentEncodeForGitRef(original):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000972 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -0800973
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000974 The encoding used is based on but stricter than URL encoding (Section 2.1 of
975 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
976 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -0800977
978 For more information, see the Gerrit docs here:
979
980 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
981 """
982 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
983 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
984
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000985 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
Nick Carter8692b182017-11-06 16:30:38 -0800986 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
987 return encoded.replace(' ', '_')
988
989
Dan Jacques8d11e482016-11-15 14:25:56 -0800990@contextlib.contextmanager
991def tempdir():
992 tdir = None
993 try:
994 tdir = tempfile.mkdtemp(suffix='gerrit_util')
995 yield tdir
996 finally:
997 if tdir:
998 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000999
1000
1001def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +00001002 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001003 this module API.
1004
1005 Such format is allows for more efficient Gerrit routing of HTTP requests,
1006 comparing to specifying just change_number.
1007 """
1008 assert int(change_number)
1009 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001010
1011
1012# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001013_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001014assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1015
1016
1017def _UseGerritMirror(url, host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001018 """Returns a new URL which uses randomly selected mirror for a Gerrit host.
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001019
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001020 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 +00001021 function.
1022
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001023 Assumes that the URL has a single occurence of the host substring.
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001024 """
1025 assert host in url
1026 suffix = '-mirror-' + host
1027 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1028 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1029 st = url.find(suffix)
1030 if st == -1:
1031 actual_host = host
1032 else:
1033 # Already uses some mirror.
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001034 assert st >= prefix_len, (url, host, st, prefix_len)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001035 prefixes.remove(url[st-prefix_len:st])
1036 actual_host = url[st-prefix_len:st+len(suffix)]
1037 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)