blob: 0f5913ccd71237438e419b7b38243a51fb58dd99 [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.')
Edward Lemur67fccdf2019-10-22 22:17:10 +0000149 url = cls.get_new_password_url(host)
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100150 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000151
152 @classmethod
153 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000154 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000155 return os.path.expanduser(os.path.join('~', path))
156
157 @classmethod
158 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800159 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000160 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000161 if not os.path.exists(path):
162 return netrc.netrc(os.devnull)
163
164 st = os.stat(path)
165 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000166 print(
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000167 'WARNING: netrc file %s cannot be used because its file '
168 'permissions are insecure. netrc file permissions should be '
Raul Tambre80ee78e2019-05-06 22:41:05 +0000169 '600.' % path, file=sys.stderr)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000170 with open(path) as fd:
171 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800172
173 # Load the '.netrc' file. We strip comments from it because processing them
174 # can trigger a bug in Windows. See crbug.com/664664.
175 content = '\n'.join(l for l in content.splitlines()
176 if l.strip() and not l.strip().startswith('#'))
177 with tempdir() as tdir:
178 netrc_path = os.path.join(tdir, 'netrc')
179 with open(netrc_path, 'w') as fd:
180 fd.write(content)
181 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
182 return cls._get_netrc_from_path(netrc_path)
183
184 @classmethod
185 def _get_netrc_from_path(cls, path):
186 try:
187 return netrc.netrc(path)
188 except IOError:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000189 print('WARNING: Could not read netrc file %s' % path, file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800190 return netrc.netrc(os.devnull)
191 except netrc.NetrcParseError as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000192 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
193 (path, e), file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800194 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000195
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000196 @classmethod
197 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500198 if os.getenv('GIT_COOKIES_PATH'):
199 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800200 try:
201 return subprocess2.check_output(
202 ['git', 'config', '--path', 'http.cookiefile']).strip()
203 except subprocess2.CalledProcessError:
204 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000205
206 @classmethod
207 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000208 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000209 path = cls.get_gitcookies_path()
210 if not os.path.exists(path):
211 return gitcookies
212
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000213 try:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000214 f = gclient_utils.FileRead(path, 'rb').splitlines()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000215 except IOError:
216 return gitcookies
217
Edward Lemur67fccdf2019-10-22 22:17:10 +0000218 for line in f:
219 try:
220 fields = line.strip().split('\t')
221 if line.strip().startswith('#') or len(fields) != 7:
222 continue
223 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
224 if xpath == '/' and key == 'o':
225 if value.startswith('git-'):
226 login, secret_token = value.split('=', 1)
227 gitcookies[domain] = (login, secret_token)
228 else:
229 gitcookies[domain] = ('', value)
230 except (IndexError, ValueError, TypeError) as exc:
231 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000232 return gitcookies
233
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100234 def _get_auth_for_host(self, host):
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000235 for domain, creds in self.gitcookies.items():
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000236 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100237 return (creds[0], None, creds[1])
238 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000239
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100240 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700241 a = self._get_auth_for_host(host)
242 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000243 if a[0]:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000244 secret = base64.b64encode(('%s:%s' % (a[0], a[2])).encode('utf-8'))
245 return 'Basic %s' % secret.decode('utf-8')
Eric Boren2fb63102018-10-05 13:05:03 +0000246 else:
247 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000248 return None
249
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100250 def get_auth_email(self, host):
251 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700252 a = self._get_auth_for_host(host)
253 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100254 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700255 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100256 # login typically looks like 'git-xxx.example.com'
257 if not login.startswith('git-') or '.' not in login:
258 return None
259 username, domain = login[len('git-'):].split('.', 1)
260 return '%s@%s' % (username, domain)
261
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100262
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000263# Backwards compatibility just in case somebody imports this outside of
264# depot_tools.
265NetrcAuthenticator = CookiesAuthenticator
266
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000267
268class GceAuthenticator(Authenticator):
269 """Authenticator implementation that uses GCE metadata service for token.
270 """
271
272 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700273 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
274 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000275 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
276
277 _cache_is_gce = None
278 _token_cache = None
279 _token_expiration = None
280
281 @classmethod
282 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500283 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
284 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000285 if cls._cache_is_gce is None:
286 cls._cache_is_gce = cls._test_is_gce()
287 return cls._cache_is_gce
288
289 @classmethod
290 def _test_is_gce(cls):
291 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
292 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100293 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100294 except (socket.error, httplib2.ServerNotFoundError,
295 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000296 # Could not resolve URL.
297 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100298 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000299
300 @staticmethod
301 def _get(url, **kwargs):
302 next_delay_sec = 1
303 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000304 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700305 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100306 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000307 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
308 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100309 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000310
Aaron Gable92e9f382017-12-07 11:47:41 -0800311 # Retry server error status codes.
312 LOGGER.warn('Encountered server error')
313 if TRY_LIMIT - i > 1:
314 LOGGER.info('Will retry in %d seconds (%d more times)...',
315 next_delay_sec, TRY_LIMIT - i - 1)
316 time.sleep(next_delay_sec)
317 next_delay_sec *= 2
318
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000319 @classmethod
320 def _get_token_dict(cls):
321 if cls._token_cache:
322 # If it expires within 25 seconds, refresh.
323 if cls._token_expiration < time.time() - 25:
324 return cls._token_cache
325
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100326 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000327 if resp.status != httplib.OK:
328 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100329 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000330 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
331 return cls._token_cache
332
333 def get_auth_header(self, _host):
334 token_dict = self._get_token_dict()
335 if not token_dict:
336 return None
337 return '%(token_type)s %(access_token)s' % token_dict
338
339
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700340class LuciContextAuthenticator(Authenticator):
341 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
342 """
343
344 @staticmethod
345 def is_luci():
346 return auth.has_luci_context_local_auth()
347
348 def __init__(self):
Edward Lemur5b929a42019-10-21 17:57:39 +0000349 self._authenticator = auth.Authenticator(
350 ' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700351
352 def get_auth_header(self, _host):
Edward Lemur5b929a42019-10-21 17:57:39 +0000353 return 'Bearer %s' % self._authenticator.get_access_token().token
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700354
355
szager@chromium.orgb4696232013-10-16 19:45:35 +0000356def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000357 """Opens an HTTPS connection to a Gerrit service, and sends a request."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000358 headers = headers or {}
359 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000360
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700361 a = Authenticator.get().get_auth_header(bare_host)
362 if a:
363 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000364 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000365 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000366
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800367 url = path
368 if not url.startswith('/'):
369 url = '/' + url
370 if 'Authorization' in headers and not url.startswith('/a/'):
371 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000372
szager@chromium.orgb4696232013-10-16 19:45:35 +0000373 if body:
374 body = json.JSONEncoder().encode(body)
375 headers.setdefault('Content-Type', 'application/json')
376 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000377 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000378 for key, val in headers.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000379 if key == 'Authorization':
380 val = 'HIDDEN'
381 LOGGER.debug('%s: %s' % (key, val))
382 if body:
383 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700384 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000385 # HACK: httplib.Http has no such attribute; we store req_host here for later
386 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000387 conn.req_host = host
388 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100389 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000390 'method': reqtype,
391 'headers': headers,
392 'body': body,
393 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000394 return conn
395
396
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700397def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000398 """Reads an HTTP response from a connection into a string buffer.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000399
400 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100401 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700402 accept_statuses: Treat any of these statuses as success. Default: [200]
403 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000404 Returns: A string buffer containing the connection's reply.
405 """
Steve Kobes56117722018-09-13 18:18:35 +0000406 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000407 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000408 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100409 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000410
Edward Lemur5a9ff432018-10-30 19:00:22 +0000411 response_time = time.time() - before_response
412 metrics.collector.add_repeated(
413 'http_requests',
414 metrics_utils.extract_http_metrics(
415 conn.req_params['uri'], conn.req_params['method'], response.status,
416 response_time))
417
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000418 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100419 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000420 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
421 www_authenticate):
422 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
423 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700424 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000425 'has credentials for %s' % host)
426 raise GerritAuthenticationError(response.status, reason)
427
szager@chromium.orgb4696232013-10-16 19:45:35 +0000428 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000429 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700430 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000431 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700432 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100433 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100434 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700435 # If 404 was in accept_statuses, then it's expected that the file might
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000436 # not exist, so don't return the gitiles error page because that's not
437 # the "content" that was actually requested.
Michael Mossb40a4512017-10-10 11:07:17 -0700438 if response.status == 404:
439 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000440 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000441 # A status >=500 is assumed to be a possible transient error; retry.
442 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
443 LOGGER.warn('A transient error occurred while querying %s:\n'
444 '%s %s %s\n'
445 '%s %d %s',
446 conn.req_host, conn.req_params['method'],
447 conn.req_params['uri'],
448 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000449 if response.status == 404:
450 # TODO(crbug/881860): remove this hack.
451 # HACK: try different Gerrit mirror as a workaround for potentially
452 # out-of-date mirror hit through default routing.
453 if conn.req_host == 'chromium-review.googlesource.com':
454 conn.req_params['uri'] = _UseGerritMirror(
455 conn.req_params['uri'], 'chromium-review.googlesource.com')
456 # And don't increase sleep_time in this case, since we suspect we've
457 # just asked wrong git mirror before.
458 sleep_time /= 2.0
459
szager@chromium.orgb4696232013-10-16 19:45:35 +0000460 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800461 LOGGER.info('Will retry in %d seconds (%d more times)...',
462 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000463 time.sleep(sleep_time)
464 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000465 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700466 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200467 if response.status in (401, 403):
468 print('Your Gerrit credentials might be misconfigured. Try: \n'
469 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100470 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000471 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100472 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000473
474
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700475def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000476 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700477 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000478 # The first line of the response should always be: )]}'
479 s = fh.readline()
480 if s and s.rstrip() != ")]}'":
481 raise GerritError(200, 'Unexpected json output: %s' % s)
482 s = fh.read()
483 if not s:
484 return None
485 return json.loads(s)
486
487
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200488def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100489 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000490 """
491 Queries a gerrit-on-borg server for changes matching query terms.
492
493 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200494 params: A list of key:value pairs for search parameters, as documented
495 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
496 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000497 first_param: A change identifier
498 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000500 o_params: A list of additional output specifiers, as documented here:
501 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000502
szager@chromium.orgb4696232013-10-16 19:45:35 +0000503 Returns:
504 A list of json-decoded query results.
505 """
506 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200507 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000508 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200509 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 if start:
511 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000512 if limit:
513 path = '%s&n=%d' % (path, limit)
514 if o_params:
515 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700516 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000517
518
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200519def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100520 o_params=None, start=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000521 """Queries a gerrit-on-borg server for all the changes matching the query
522 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000523
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100524 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000525 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100526
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000527 A single query to gerrit-on-borg is limited on the number of results by the
528 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100529 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000530
531 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200532 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000533 limit: Maximum number of requested changes per query.
534 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100535 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000536
537 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100538 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000539 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100540 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000541
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100542 def at_most_once(cls):
543 for cl in cls:
544 if cl['_number'] not in already_returned:
545 already_returned.add(cl['_number'])
546 yield cl
547
548 start = start or 0
549 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000550 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100551
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000552 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100553 # This will fetch changes[start..start+limit] sorted by most recently
554 # updated. Since the rank of any change in this list can be changed any time
555 # (say user posting comment), subsequent calls may overalp like this:
556 # > initial order ABCDEFGH
557 # query[0..3] => ABC
558 # > E get's updated. New order: EABCDFGH
559 # query[3..6] => CDF # C is a dup
560 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200561 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100562 cur_start)
563 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000564 yield cl
565
566 more_changes = [cl for cl in page if '_more_changes' in cl]
567 if len(more_changes) > 1:
568 raise GerritError(
569 200,
570 'Received %d changes with a _more_changes attribute set but should '
571 'receive at most one.' % len(more_changes))
572 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100573 cur_start += len(page)
574
575 # If we paged through, query again the first page which in most circumstances
576 # will fetch all changes that were modified while this function was run.
577 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200578 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100579 for cl in at_most_once(page):
580 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000581
582
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200583def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100584 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000585 """Initiate a query composed of multiple sets of query parameters."""
586 if not change_list:
587 raise RuntimeError(
588 "MultiQueryChanges requires a list of change numbers/id's")
589 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200590 if params:
591 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000592 if limit:
593 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100594 if start:
595 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000596 if o_params:
597 q.extend(['o=%s' % p for p in o_params])
598 path = 'changes/?%s' % '&'.join(q)
599 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700600 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000601 except GerritError as e:
602 msg = '%s:\n%s' % (e.message, path)
603 raise GerritError(e.http_status, msg)
604 return result
605
606
607def GetGerritFetchUrl(host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000608 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000609 return '%s://%s/' % (GERRIT_PROTOCOL, host)
610
611
Edward Lemur687ca902018-12-05 02:30:30 +0000612def GetCodeReviewTbrScore(host, project):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000613 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000614 """
615 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
616 project = ReadHttpJsonResponse(conn)
617 if ('labels' not in project
618 or 'Code-Review' not in project['labels']
619 or 'values' not in project['labels']['Code-Review']):
620 return 1
621 return max([int(x) for x in project['labels']['Code-Review']['values']])
622
623
szager@chromium.orgb4696232013-10-16 19:45:35 +0000624def GetChangePageUrl(host, change_number):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000625 """Given a Gerrit host name and change number, returns change page URL."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000626 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
627
628
629def GetChangeUrl(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000630 """Given a Gerrit host name and change ID, returns a URL for the change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000631 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
632
633
634def GetChange(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000635 """Queries a Gerrit server for information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000636 path = 'changes/%s' % change
637 return ReadHttpJsonResponse(CreateHttpConn(host, path))
638
639
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700640def GetChangeDetail(host, change, o_params=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000641 """Queries a Gerrit server for extended information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000642 path = 'changes/%s/detail' % change
643 if o_params:
644 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700645 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000646
647
agable32978d92016-11-01 12:55:02 -0700648def GetChangeCommit(host, change, revision='current'):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000649 """Query a Gerrit server for a revision associated with a change."""
agable32978d92016-11-01 12:55:02 -0700650 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
651 return ReadHttpJsonResponse(CreateHttpConn(host, path))
652
653
szager@chromium.orgb4696232013-10-16 19:45:35 +0000654def GetChangeCurrentRevision(host, change):
655 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200656 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000657
658
659def GetChangeRevisions(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000660 """Gets information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200661 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000662
663
664def GetChangeReview(host, change, revision=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000665 """Gets the current review information for a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000666 if not revision:
667 jmsg = GetChangeRevisions(host, change)
668 if not jmsg:
669 return None
670 elif len(jmsg) > 1:
671 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
672 revision = jmsg[0]['current_revision']
673 path = 'changes/%s/revisions/%s/review'
674 return ReadHttpJsonResponse(CreateHttpConn(host, path))
675
676
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700677def GetChangeComments(host, change):
678 """Get the line- and file-level comments on a change."""
679 path = 'changes/%s/comments' % change
680 return ReadHttpJsonResponse(CreateHttpConn(host, path))
681
682
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000683def GetChangeRobotComments(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000684 """Gets the line- and file-level robot comments on a change."""
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000685 path = 'changes/%s/robotcomments' % change
686 return ReadHttpJsonResponse(CreateHttpConn(host, path))
687
688
szager@chromium.orgb4696232013-10-16 19:45:35 +0000689def AbandonChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000690 """Abandons a Gerrit change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000691 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000692 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000693 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700694 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000695
696
697def RestoreChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000698 """Restores a previously abandoned change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000699 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000700 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000701 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700702 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000703
704
705def SubmitChange(host, change, wait_for_merge=True):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000706 """Submits a Gerrit change via Gerrit."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000707 path = 'changes/%s/submit' % change
708 body = {'wait_for_merge': wait_for_merge}
709 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700710 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000711
712
dsansomee2d6fd92016-09-08 00:10:47 -0700713def HasPendingChangeEdit(host, change):
714 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
715 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700716 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700717 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700718 # 204 No Content means no pending change.
719 if e.http_status == 204:
720 return False
721 raise
722 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700723
724
725def DeletePendingChangeEdit(host, change):
726 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000727 # On success, Gerrit returns status 204; if the edit was already deleted it
Aaron Gable19ee16c2017-04-18 11:56:35 -0700728 # returns 404. Anything else is an error.
729 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700730
731
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100732def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000733 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700734 assert notify in ('ALL', 'NONE')
735 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700736 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700737 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000738 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700739 ReadHttpResponse(conn, accept_statuses=[200, 204])
740 except GerritError as e:
741 raise GerritError(
742 e.http_status,
743 'Received unexpected http status while editing message '
744 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000745
746
szager@chromium.orgb4696232013-10-16 19:45:35 +0000747def GetReviewers(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000748 """Gets information about all reviewers attached to a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000749 path = 'changes/%s/reviewers' % change
750 return ReadHttpJsonResponse(CreateHttpConn(host, path))
751
752
753def GetReview(host, change, revision):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000754 """Gets review information about a specific revision of a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000755 path = 'changes/%s/revisions/%s/review' % (change, revision)
756 return ReadHttpJsonResponse(CreateHttpConn(host, path))
757
758
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700759def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
760 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000761 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700762 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800763 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200764 if not change:
765 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700766 reviewers = frozenset(reviewers or [])
767 ccs = frozenset(ccs or [])
768 path = 'changes/%s/revisions/current/review' % change
769
770 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800771 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700772 'reviewers': [],
773 'notify': 'ALL' if notify else 'NONE',
774 }
775 for r in sorted(reviewers | ccs):
776 state = 'REVIEWER' if r in reviewers else 'CC'
777 body['reviewers'].append({
778 'reviewer': r,
779 'state': state,
780 'notify': 'NONE', # We handled `notify` argument above.
Raul Tambre80ee78e2019-05-06 22:41:05 +0000781 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700782
783 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
784 # Gerrit will return 400 if one or more of the requested reviewers are
785 # unprocessable. We read the response object to see which were rejected,
786 # warn about them, and retry with the remainder.
787 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
788
789 errored = set()
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000790 for result in resp.get('reviewers', {}).values():
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700791 r = result.get('input')
792 state = 'REVIEWER' if r in reviewers else 'CC'
793 if result.get('error'):
794 errored.add(r)
795 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
796 if errored:
797 # Try again, adding only those that didn't fail, and only accepting 200.
798 AddReviewers(host, change, reviewers=(reviewers-errored),
799 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000800
801
802def RemoveReviewers(host, change, remove=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000803 """Removes reviewers from a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000804 if not remove:
805 return
806 if isinstance(remove, basestring):
807 remove = (remove,)
808 for r in remove:
809 path = 'changes/%s/reviewers/%s' % (change, r)
810 conn = CreateHttpConn(host, path, reqtype='DELETE')
811 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700812 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000813 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000814 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700815 e.http_status,
816 'Received unexpected http status while deleting reviewer "%s" '
817 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000818
819
Aaron Gable636b13f2017-07-14 10:42:48 -0700820def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000821 """Sets labels and/or adds a message to a code review."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000822 if not msg and not labels:
823 return
824 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800825 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000826 if msg:
827 body['message'] = msg
828 if labels:
829 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700830 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700831 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700832 if ready:
833 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000834 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
835 response = ReadHttpJsonResponse(conn)
836 if labels:
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000837 for key, val in labels.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000838 if ('labels' not in response or key not in response['labels'] or
839 int(response['labels'][key] != int(val))):
840 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
841 key, change))
842
843
844def ResetReviewLabels(host, change, label, value='0', message=None,
845 notify=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000846 """Resets the value of a given label for all reviewers on a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000847 # This is tricky, because we want to work on the "current revision", but
848 # there's always the risk that "current revision" will change in between
849 # API calls. So, we check "current revision" at the beginning and end; if
850 # it has changed, raise an exception.
851 jmsg = GetChangeCurrentRevision(host, change)
852 if not jmsg:
853 raise GerritError(
854 200, 'Could not get review information for change "%s"' % change)
855 value = str(value)
856 revision = jmsg[0]['current_revision']
857 path = 'changes/%s/revisions/%s/review' % (change, revision)
858 message = message or (
859 '%s label set to %s programmatically.' % (label, value))
860 jmsg = GetReview(host, change, revision)
861 if not jmsg:
862 raise GerritError(200, 'Could not get review information for revison %s '
863 'of change %s' % (revision, change))
864 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
865 if str(review.get('value', value)) != value:
866 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800867 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000868 'message': message,
869 'labels': {label: value},
870 'on_behalf_of': review['_account_id'],
871 }
872 if notify:
873 body['notify'] = notify
874 conn = CreateHttpConn(
875 host, path, reqtype='POST', body=body)
876 response = ReadHttpJsonResponse(conn)
877 if str(response['labels'][label]) != value:
878 username = review.get('email', jmsg.get('name', ''))
879 raise GerritError(200, 'Unable to set %s label for user "%s"'
880 ' on change %s.' % (label, username, change))
881 jmsg = GetChangeCurrentRevision(host, change)
882 if not jmsg:
883 raise GerritError(
884 200, 'Could not get review information for change "%s"' % change)
885 elif jmsg[0]['current_revision'] != revision:
886 raise GerritError(200, 'While resetting labels on change "%s", '
887 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800888
889
dimu833c94c2017-01-18 17:36:15 -0800890def CreateGerritBranch(host, project, branch, commit):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000891 """Creates a new branch from given project and commit
892
dimu833c94c2017-01-18 17:36:15 -0800893 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
894
895 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000896 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -0800897 """
898 path = 'projects/%s/branches/%s' % (project, branch)
899 body = {'revision': commit}
900 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700901 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800902 if response:
903 return response
904 raise GerritError(200, 'Unable to create gerrit branch')
905
906
907def GetGerritBranch(host, project, branch):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000908 """Gets a branch from given project and commit.
909
910 See:
dimu833c94c2017-01-18 17:36:15 -0800911 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
912
913 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000914 A JSON object with 'revision' key.
dimu833c94c2017-01-18 17:36:15 -0800915 """
916 path = 'projects/%s/branches/%s' % (project, branch)
917 conn = CreateHttpConn(host, path, reqtype='GET')
918 response = ReadHttpJsonResponse(conn)
919 if response:
920 return response
921 raise GerritError(200, 'Unable to get gerrit branch')
922
923
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100924def GetAccountDetails(host, account_id='self'):
925 """Returns details of the account.
926
927 If account_id is not given, uses magic value 'self' which corresponds to
928 whichever account user is authenticating as.
929
930 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000931 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000932
933 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100934 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100935 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000936 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
937
938
939def ValidAccounts(host, accounts, max_threads=10):
940 """Returns a mapping from valid account to its details.
941
942 Invalid accounts, either not existing or without unique match,
943 are not present as returned dictionary keys.
944 """
945 assert not isinstance(accounts, basestring), type(accounts)
946 accounts = list(set(accounts))
947 if not accounts:
948 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000949
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000950 def get_one(account):
951 try:
952 return account, GetAccountDetails(host, account)
953 except GerritError:
954 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000955
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000956 valid = {}
957 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
958 for account, details in pool.map(get_one, accounts):
959 if account and details:
960 valid[account] = details
961 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100962
963
Nick Carter8692b182017-11-06 16:30:38 -0800964def PercentEncodeForGitRef(original):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000965 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -0800966
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000967 The encoding used is based on but stricter than URL encoding (Section 2.1 of
968 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
969 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -0800970
971 For more information, see the Gerrit docs here:
972
973 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
974 """
975 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
976 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
977
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000978 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
Nick Carter8692b182017-11-06 16:30:38 -0800979 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
980 return encoded.replace(' ', '_')
981
982
Dan Jacques8d11e482016-11-15 14:25:56 -0800983@contextlib.contextmanager
984def tempdir():
985 tdir = None
986 try:
987 tdir = tempfile.mkdtemp(suffix='gerrit_util')
988 yield tdir
989 finally:
990 if tdir:
991 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000992
993
994def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +0000995 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000996 this module API.
997
998 Such format is allows for more efficient Gerrit routing of HTTP requests,
999 comparing to specifying just change_number.
1000 """
1001 assert int(change_number)
1002 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001003
1004
1005# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001006_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001007assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1008
1009
1010def _UseGerritMirror(url, host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001011 """Returns a new URL which uses randomly selected mirror for a Gerrit host.
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001012
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001013 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 +00001014 function.
1015
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001016 Assumes that the URL has a single occurence of the host substring.
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001017 """
1018 assert host in url
1019 suffix = '-mirror-' + host
1020 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1021 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1022 st = url.find(suffix)
1023 if st == -1:
1024 actual_host = host
1025 else:
1026 # Already uses some mirror.
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001027 assert st >= prefix_len, (url, host, st, prefix_len)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001028 prefixes.remove(url[st-prefix_len:st])
1029 actual_host = url[st-prefix_len:st+len(suffix)]
1030 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)