blob: 9e694cf37fa90d921b44b740ceeea4c9a23d5850 [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.
szager@chromium.orgb4696232013-10-16 19:45:35 +000017import json
18import logging
19import netrc
20import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000021import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000022import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000023import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000024import stat
25import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080026import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000027import time
28import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000029import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000030from cStringIO import StringIO
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000031from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000032
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070033import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080034import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000035import metrics
36import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080037import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010038from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000039
szager@chromium.orgb4696232013-10-16 19:45:35 +000040LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000041# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
42# total tries, the sleep time between the first and last tries will be 94.5 sec.
43# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
44TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000045
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000046
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +000047# Controls the transport protocol used to communicate with Gerrit.
szager@chromium.orgb4696232013-10-16 19:45:35 +000048# This is parameterized primarily to enable GerritTestCase.
49GERRIT_PROTOCOL = 'https'
50
51
52class GerritError(Exception):
53 """Exception class for errors commuicating with the gerrit-on-borg service."""
54 def __init__(self, http_status, *args, **kwargs):
55 super(GerritError, self).__init__(*args, **kwargs)
56 self.http_status = http_status
57 self.message = '(%d) %s' % (self.http_status, self.message)
58
59
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000060class GerritAuthenticationError(GerritError):
61 """Exception class for authentication errors during Gerrit communication."""
62
63
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020064def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000065 """Encodes query parameters in the key:val[+key:val...] format specified here:
66
67 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
68 """
69 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020070 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000071 return '+'.join(q)
72
73
Aaron Gabled2db5a22017-03-24 14:14:15 -070074def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000075 if protocol is None:
76 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010077 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070078 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000079 else:
80 raise RuntimeError(
81 "Don't know how to work with protocol '%s'" % protocol)
82
83
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000084class Authenticator(object):
85 """Base authenticator class for authenticator implementations to subclass."""
86
87 def get_auth_header(self, host):
88 raise NotImplementedError()
89
90 @staticmethod
91 def get():
92 """Returns: (Authenticator) The identified Authenticator to use.
93
94 Probes the local system and its environment and identifies the
95 Authenticator instance to use.
96 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070097 # LUCI Context takes priority since it's normally present only on bots,
98 # which then must use it.
99 if LuciContextAuthenticator.is_luci():
100 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000101 if GceAuthenticator.is_gce():
102 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000103 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000104
105
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000106class CookiesAuthenticator(Authenticator):
107 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
108
109 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000110 """
111
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000112 _EMPTY = object()
113
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000114 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000115 # Credentials will be loaded lazily on first use. This ensures Authenticator
116 # get() can always construct an authenticator, even if something is broken.
117 # This allows 'creds-check' to proceed to actually checking creds later,
118 # rigorously (instead of blowing up with a cryptic error if they are wrong).
119 self._netrc = self._EMPTY
120 self._gitcookies = self._EMPTY
121
122 @property
123 def netrc(self):
124 if self._netrc is self._EMPTY:
125 self._netrc = self._get_netrc()
126 return self._netrc
127
128 @property
129 def gitcookies(self):
130 if self._gitcookies is self._EMPTY:
131 self._gitcookies = self._get_gitcookies()
132 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000133
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000134 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200135 def get_new_password_url(cls, host):
136 assert not host.startswith('http')
137 # Assume *.googlesource.com pattern.
138 parts = host.split('.')
139 if not parts[0].endswith('-review'):
140 parts[0] += '-review'
141 return 'https://%s/new-password' % ('.'.join(parts))
142
143 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000144 def get_new_password_message(cls, host):
William Hessee9e89e32019-03-03 19:02:32 +0000145 if host is None:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000146 return ('Git host for Gerrit upload is unknown. Check your remote '
William Hessee9e89e32019-03-03 19:02:32 +0000147 'and the branch your branch is tracking. This tool assumes '
148 'that you are using a git server at *.googlesource.com.')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000149 assert not host.startswith('http')
150 # Assume *.googlesource.com pattern.
151 parts = host.split('.')
152 if not parts[0].endswith('-review'):
153 parts[0] += '-review'
154 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100155 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000156
157 @classmethod
158 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000159 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000160 return os.path.expanduser(os.path.join('~', path))
161
162 @classmethod
163 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800164 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000165 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000166 if not os.path.exists(path):
167 return netrc.netrc(os.devnull)
168
169 st = os.stat(path)
170 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000171 print(
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000172 'WARNING: netrc file %s cannot be used because its file '
173 'permissions are insecure. netrc file permissions should be '
Raul Tambre80ee78e2019-05-06 22:41:05 +0000174 '600.' % path, file=sys.stderr)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000175 with open(path) as fd:
176 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800177
178 # Load the '.netrc' file. We strip comments from it because processing them
179 # can trigger a bug in Windows. See crbug.com/664664.
180 content = '\n'.join(l for l in content.splitlines()
181 if l.strip() and not l.strip().startswith('#'))
182 with tempdir() as tdir:
183 netrc_path = os.path.join(tdir, 'netrc')
184 with open(netrc_path, 'w') as fd:
185 fd.write(content)
186 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
187 return cls._get_netrc_from_path(netrc_path)
188
189 @classmethod
190 def _get_netrc_from_path(cls, path):
191 try:
192 return netrc.netrc(path)
193 except IOError:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000194 print('WARNING: Could not read netrc file %s' % path, file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800195 return netrc.netrc(os.devnull)
196 except netrc.NetrcParseError as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000197 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
198 (path, e), file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800199 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000200
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000201 @classmethod
202 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500203 if os.getenv('GIT_COOKIES_PATH'):
204 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800205 try:
206 return subprocess2.check_output(
207 ['git', 'config', '--path', 'http.cookiefile']).strip()
208 except subprocess2.CalledProcessError:
209 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000210
211 @classmethod
212 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000213 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000214 path = cls.get_gitcookies_path()
215 if not os.path.exists(path):
216 return gitcookies
217
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000218 try:
219 f = open(path, 'rb')
220 except IOError:
221 return gitcookies
222
223 with f:
224 for line in f:
225 try:
226 fields = line.strip().split('\t')
227 if line.strip().startswith('#') or len(fields) != 7:
228 continue
229 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
230 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000231 if value.startswith('git-'):
232 login, secret_token = value.split('=', 1)
233 gitcookies[domain] = (login, secret_token)
234 else:
235 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000236 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100237 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000238 return gitcookies
239
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100240 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000241 for domain, creds in self.gitcookies.iteritems():
242 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100243 return (creds[0], None, creds[1])
244 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000245
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100246 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700247 a = self._get_auth_for_host(host)
248 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000249 if a[0]:
250 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
251 else:
252 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000253 return None
254
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100255 def get_auth_email(self, host):
256 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700257 a = self._get_auth_for_host(host)
258 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100259 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700260 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100261 # login typically looks like 'git-xxx.example.com'
262 if not login.startswith('git-') or '.' not in login:
263 return None
264 username, domain = login[len('git-'):].split('.', 1)
265 return '%s@%s' % (username, domain)
266
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100267
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000268# Backwards compatibility just in case somebody imports this outside of
269# depot_tools.
270NetrcAuthenticator = CookiesAuthenticator
271
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000272
273class GceAuthenticator(Authenticator):
274 """Authenticator implementation that uses GCE metadata service for token.
275 """
276
277 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700278 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
279 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000280 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
281
282 _cache_is_gce = None
283 _token_cache = None
284 _token_expiration = None
285
286 @classmethod
287 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500288 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
289 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000290 if cls._cache_is_gce is None:
291 cls._cache_is_gce = cls._test_is_gce()
292 return cls._cache_is_gce
293
294 @classmethod
295 def _test_is_gce(cls):
296 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
297 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100298 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100299 except (socket.error, httplib2.ServerNotFoundError,
300 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000301 # Could not resolve URL.
302 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100303 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000304
305 @staticmethod
306 def _get(url, **kwargs):
307 next_delay_sec = 1
308 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000309 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700310 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100311 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000312 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
313 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100314 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000315
Aaron Gable92e9f382017-12-07 11:47:41 -0800316 # Retry server error status codes.
317 LOGGER.warn('Encountered server error')
318 if TRY_LIMIT - i > 1:
319 LOGGER.info('Will retry in %d seconds (%d more times)...',
320 next_delay_sec, TRY_LIMIT - i - 1)
321 time.sleep(next_delay_sec)
322 next_delay_sec *= 2
323
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000324 @classmethod
325 def _get_token_dict(cls):
326 if cls._token_cache:
327 # If it expires within 25 seconds, refresh.
328 if cls._token_expiration < time.time() - 25:
329 return cls._token_cache
330
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100331 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000332 if resp.status != httplib.OK:
333 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100334 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000335 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
336 return cls._token_cache
337
338 def get_auth_header(self, _host):
339 token_dict = self._get_token_dict()
340 if not token_dict:
341 return None
342 return '%(token_type)s %(access_token)s' % token_dict
343
344
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700345class LuciContextAuthenticator(Authenticator):
346 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
347 """
348
349 @staticmethod
350 def is_luci():
351 return auth.has_luci_context_local_auth()
352
353 def __init__(self):
354 self._access_token = None
355 self._ensure_fresh()
356
357 def _ensure_fresh(self):
358 if not self._access_token or self._access_token.needs_refresh():
359 self._access_token = auth.get_luci_context_access_token(
360 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
361
362 def get_auth_header(self, _host):
363 self._ensure_fresh()
364 return 'Bearer %s' % self._access_token.token
365
366
szager@chromium.orgb4696232013-10-16 19:45:35 +0000367def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000368 """Opens an HTTPS connection to a Gerrit service, and sends a request."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000369 headers = headers or {}
370 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000371
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700372 a = Authenticator.get().get_auth_header(bare_host)
373 if a:
374 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000375 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000376 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000377
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800378 url = path
379 if not url.startswith('/'):
380 url = '/' + url
381 if 'Authorization' in headers and not url.startswith('/a/'):
382 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000383
szager@chromium.orgb4696232013-10-16 19:45:35 +0000384 if body:
385 body = json.JSONEncoder().encode(body)
386 headers.setdefault('Content-Type', 'application/json')
387 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000388 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000389 for key, val in headers.iteritems():
390 if key == 'Authorization':
391 val = 'HIDDEN'
392 LOGGER.debug('%s: %s' % (key, val))
393 if body:
394 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700395 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000396 # HACK: httplib.Http has no such attribute; we store req_host here for later
397 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000398 conn.req_host = host
399 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100400 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000401 'method': reqtype,
402 'headers': headers,
403 'body': body,
404 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000405 return conn
406
407
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700408def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000409 """Reads an HTTP response from a connection into a string buffer.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000410
411 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100412 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700413 accept_statuses: Treat any of these statuses as success. Default: [200]
414 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000415 Returns: A string buffer containing the connection's reply.
416 """
Steve Kobes56117722018-09-13 18:18:35 +0000417 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000418 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000419 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100420 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000421
Edward Lemur5a9ff432018-10-30 19:00:22 +0000422 response_time = time.time() - before_response
423 metrics.collector.add_repeated(
424 'http_requests',
425 metrics_utils.extract_http_metrics(
426 conn.req_params['uri'], conn.req_params['method'], response.status,
427 response_time))
428
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000429 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100430 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000431 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
432 www_authenticate):
433 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
434 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700435 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000436 'has credentials for %s' % host)
437 raise GerritAuthenticationError(response.status, reason)
438
szager@chromium.orgb4696232013-10-16 19:45:35 +0000439 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000440 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700441 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000442 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700443 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100444 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100445 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700446 # If 404 was in accept_statuses, then it's expected that the file might
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000447 # not exist, so don't return the gitiles error page because that's not
448 # the "content" that was actually requested.
Michael Mossb40a4512017-10-10 11:07:17 -0700449 if response.status == 404:
450 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000451 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000452 # A status >=500 is assumed to be a possible transient error; retry.
453 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
454 LOGGER.warn('A transient error occurred while querying %s:\n'
455 '%s %s %s\n'
456 '%s %d %s',
457 conn.req_host, conn.req_params['method'],
458 conn.req_params['uri'],
459 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000460 if response.status == 404:
461 # TODO(crbug/881860): remove this hack.
462 # HACK: try different Gerrit mirror as a workaround for potentially
463 # out-of-date mirror hit through default routing.
464 if conn.req_host == 'chromium-review.googlesource.com':
465 conn.req_params['uri'] = _UseGerritMirror(
466 conn.req_params['uri'], 'chromium-review.googlesource.com')
467 # And don't increase sleep_time in this case, since we suspect we've
468 # just asked wrong git mirror before.
469 sleep_time /= 2.0
470
szager@chromium.orgb4696232013-10-16 19:45:35 +0000471 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800472 LOGGER.info('Will retry in %d seconds (%d more times)...',
473 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000474 time.sleep(sleep_time)
475 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000476 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700477 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200478 if response.status in (401, 403):
479 print('Your Gerrit credentials might be misconfigured. Try: \n'
480 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100481 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000482 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100483 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000484
485
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700486def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000487 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700488 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000489 # The first line of the response should always be: )]}'
490 s = fh.readline()
491 if s and s.rstrip() != ")]}'":
492 raise GerritError(200, 'Unexpected json output: %s' % s)
493 s = fh.read()
494 if not s:
495 return None
496 return json.loads(s)
497
498
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200499def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100500 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000501 """
502 Queries a gerrit-on-borg server for changes matching query terms.
503
504 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200505 params: A list of key:value pairs for search parameters, as documented
506 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
507 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000508 first_param: A change identifier
509 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000511 o_params: A list of additional output specifiers, as documented here:
512 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000513
szager@chromium.orgb4696232013-10-16 19:45:35 +0000514 Returns:
515 A list of json-decoded query results.
516 """
517 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200518 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000519 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200520 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100521 if start:
522 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000523 if limit:
524 path = '%s&n=%d' % (path, limit)
525 if o_params:
526 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700527 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000528
529
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200530def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 o_params=None, start=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000532 """Queries a gerrit-on-borg server for all the changes matching the query
533 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000534
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100535 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000536 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100537
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000538 A single query to gerrit-on-borg is limited on the number of results by the
539 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100540 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000541
542 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200543 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000544 limit: Maximum number of requested changes per query.
545 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100546 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000547
548 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100549 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000550 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100551 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000552
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100553 def at_most_once(cls):
554 for cl in cls:
555 if cl['_number'] not in already_returned:
556 already_returned.add(cl['_number'])
557 yield cl
558
559 start = start or 0
560 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000561 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100562
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000563 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100564 # This will fetch changes[start..start+limit] sorted by most recently
565 # updated. Since the rank of any change in this list can be changed any time
566 # (say user posting comment), subsequent calls may overalp like this:
567 # > initial order ABCDEFGH
568 # query[0..3] => ABC
569 # > E get's updated. New order: EABCDFGH
570 # query[3..6] => CDF # C is a dup
571 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200572 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100573 cur_start)
574 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000575 yield cl
576
577 more_changes = [cl for cl in page if '_more_changes' in cl]
578 if len(more_changes) > 1:
579 raise GerritError(
580 200,
581 'Received %d changes with a _more_changes attribute set but should '
582 'receive at most one.' % len(more_changes))
583 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100584 cur_start += len(page)
585
586 # If we paged through, query again the first page which in most circumstances
587 # will fetch all changes that were modified while this function was run.
588 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200589 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100590 for cl in at_most_once(page):
591 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000592
593
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200594def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100595 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000596 """Initiate a query composed of multiple sets of query parameters."""
597 if not change_list:
598 raise RuntimeError(
599 "MultiQueryChanges requires a list of change numbers/id's")
600 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200601 if params:
602 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000603 if limit:
604 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100605 if start:
606 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000607 if o_params:
608 q.extend(['o=%s' % p for p in o_params])
609 path = 'changes/?%s' % '&'.join(q)
610 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700611 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000612 except GerritError as e:
613 msg = '%s:\n%s' % (e.message, path)
614 raise GerritError(e.http_status, msg)
615 return result
616
617
618def GetGerritFetchUrl(host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000619 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000620 return '%s://%s/' % (GERRIT_PROTOCOL, host)
621
622
Edward Lemur687ca902018-12-05 02:30:30 +0000623def GetCodeReviewTbrScore(host, project):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000624 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000625 """
626 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
627 project = ReadHttpJsonResponse(conn)
628 if ('labels' not in project
629 or 'Code-Review' not in project['labels']
630 or 'values' not in project['labels']['Code-Review']):
631 return 1
632 return max([int(x) for x in project['labels']['Code-Review']['values']])
633
634
szager@chromium.orgb4696232013-10-16 19:45:35 +0000635def GetChangePageUrl(host, change_number):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000636 """Given a Gerrit host name and change number, returns change page URL."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000637 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
638
639
640def GetChangeUrl(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000641 """Given a Gerrit host name and change ID, returns a URL for the change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000642 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
643
644
645def GetChange(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000646 """Queries a Gerrit server for information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000647 path = 'changes/%s' % change
648 return ReadHttpJsonResponse(CreateHttpConn(host, path))
649
650
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700651def GetChangeDetail(host, change, o_params=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000652 """Queries a Gerrit server for extended information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000653 path = 'changes/%s/detail' % change
654 if o_params:
655 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700656 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000657
658
agable32978d92016-11-01 12:55:02 -0700659def GetChangeCommit(host, change, revision='current'):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000660 """Query a Gerrit server for a revision associated with a change."""
agable32978d92016-11-01 12:55:02 -0700661 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
662 return ReadHttpJsonResponse(CreateHttpConn(host, path))
663
664
szager@chromium.orgb4696232013-10-16 19:45:35 +0000665def GetChangeCurrentRevision(host, change):
666 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200667 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000668
669
670def GetChangeRevisions(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000671 """Gets information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200672 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000673
674
675def GetChangeReview(host, change, revision=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000676 """Gets the current review information for a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000677 if not revision:
678 jmsg = GetChangeRevisions(host, change)
679 if not jmsg:
680 return None
681 elif len(jmsg) > 1:
682 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
683 revision = jmsg[0]['current_revision']
684 path = 'changes/%s/revisions/%s/review'
685 return ReadHttpJsonResponse(CreateHttpConn(host, path))
686
687
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700688def GetChangeComments(host, change):
689 """Get the line- and file-level comments on a change."""
690 path = 'changes/%s/comments' % change
691 return ReadHttpJsonResponse(CreateHttpConn(host, path))
692
693
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000694def GetChangeRobotComments(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000695 """Gets the line- and file-level robot comments on a change."""
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000696 path = 'changes/%s/robotcomments' % change
697 return ReadHttpJsonResponse(CreateHttpConn(host, path))
698
699
szager@chromium.orgb4696232013-10-16 19:45:35 +0000700def AbandonChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000701 """Abandons a Gerrit change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000702 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000703 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000704 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700705 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000706
707
708def RestoreChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000709 """Restores a previously abandoned change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000710 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000711 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000712 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700713 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000714
715
716def SubmitChange(host, change, wait_for_merge=True):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000717 """Submits a Gerrit change via Gerrit."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000718 path = 'changes/%s/submit' % change
719 body = {'wait_for_merge': wait_for_merge}
720 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700721 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000722
723
dsansomee2d6fd92016-09-08 00:10:47 -0700724def HasPendingChangeEdit(host, change):
725 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
726 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700727 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700728 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700729 # 204 No Content means no pending change.
730 if e.http_status == 204:
731 return False
732 raise
733 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700734
735
736def DeletePendingChangeEdit(host, change):
737 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000738 # On success, Gerrit returns status 204; if the edit was already deleted it
Aaron Gable19ee16c2017-04-18 11:56:35 -0700739 # returns 404. Anything else is an error.
740 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700741
742
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100743def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000744 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700745 assert notify in ('ALL', 'NONE')
746 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700747 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700748 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000749 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700750 ReadHttpResponse(conn, accept_statuses=[200, 204])
751 except GerritError as e:
752 raise GerritError(
753 e.http_status,
754 'Received unexpected http status while editing message '
755 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000756
757
szager@chromium.orgb4696232013-10-16 19:45:35 +0000758def GetReviewers(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000759 """Gets information about all reviewers attached to a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000760 path = 'changes/%s/reviewers' % change
761 return ReadHttpJsonResponse(CreateHttpConn(host, path))
762
763
764def GetReview(host, change, revision):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000765 """Gets review information about a specific revision of a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000766 path = 'changes/%s/revisions/%s/review' % (change, revision)
767 return ReadHttpJsonResponse(CreateHttpConn(host, path))
768
769
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700770def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
771 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000772 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700773 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800774 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200775 if not change:
776 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700777 reviewers = frozenset(reviewers or [])
778 ccs = frozenset(ccs or [])
779 path = 'changes/%s/revisions/current/review' % change
780
781 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800782 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700783 'reviewers': [],
784 'notify': 'ALL' if notify else 'NONE',
785 }
786 for r in sorted(reviewers | ccs):
787 state = 'REVIEWER' if r in reviewers else 'CC'
788 body['reviewers'].append({
789 'reviewer': r,
790 'state': state,
791 'notify': 'NONE', # We handled `notify` argument above.
Raul Tambre80ee78e2019-05-06 22:41:05 +0000792 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700793
794 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
795 # Gerrit will return 400 if one or more of the requested reviewers are
796 # unprocessable. We read the response object to see which were rejected,
797 # warn about them, and retry with the remainder.
798 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
799
800 errored = set()
801 for result in resp.get('reviewers', {}).itervalues():
802 r = result.get('input')
803 state = 'REVIEWER' if r in reviewers else 'CC'
804 if result.get('error'):
805 errored.add(r)
806 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
807 if errored:
808 # Try again, adding only those that didn't fail, and only accepting 200.
809 AddReviewers(host, change, reviewers=(reviewers-errored),
810 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000811
812
813def RemoveReviewers(host, change, remove=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000814 """Removes reviewers from a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000815 if not remove:
816 return
817 if isinstance(remove, basestring):
818 remove = (remove,)
819 for r in remove:
820 path = 'changes/%s/reviewers/%s' % (change, r)
821 conn = CreateHttpConn(host, path, reqtype='DELETE')
822 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700823 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000824 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000825 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700826 e.http_status,
827 'Received unexpected http status while deleting reviewer "%s" '
828 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000829
830
Aaron Gable636b13f2017-07-14 10:42:48 -0700831def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000832 """Sets labels and/or adds a message to a code review."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000833 if not msg and not labels:
834 return
835 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800836 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000837 if msg:
838 body['message'] = msg
839 if labels:
840 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700841 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700842 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700843 if ready:
844 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000845 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
846 response = ReadHttpJsonResponse(conn)
847 if labels:
848 for key, val in labels.iteritems():
849 if ('labels' not in response or key not in response['labels'] or
850 int(response['labels'][key] != int(val))):
851 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
852 key, change))
853
854
855def ResetReviewLabels(host, change, label, value='0', message=None,
856 notify=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000857 """Resets the value of a given label for all reviewers on a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000858 # This is tricky, because we want to work on the "current revision", but
859 # there's always the risk that "current revision" will change in between
860 # API calls. So, we check "current revision" at the beginning and end; if
861 # it has changed, raise an exception.
862 jmsg = GetChangeCurrentRevision(host, change)
863 if not jmsg:
864 raise GerritError(
865 200, 'Could not get review information for change "%s"' % change)
866 value = str(value)
867 revision = jmsg[0]['current_revision']
868 path = 'changes/%s/revisions/%s/review' % (change, revision)
869 message = message or (
870 '%s label set to %s programmatically.' % (label, value))
871 jmsg = GetReview(host, change, revision)
872 if not jmsg:
873 raise GerritError(200, 'Could not get review information for revison %s '
874 'of change %s' % (revision, change))
875 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
876 if str(review.get('value', value)) != value:
877 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800878 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000879 'message': message,
880 'labels': {label: value},
881 'on_behalf_of': review['_account_id'],
882 }
883 if notify:
884 body['notify'] = notify
885 conn = CreateHttpConn(
886 host, path, reqtype='POST', body=body)
887 response = ReadHttpJsonResponse(conn)
888 if str(response['labels'][label]) != value:
889 username = review.get('email', jmsg.get('name', ''))
890 raise GerritError(200, 'Unable to set %s label for user "%s"'
891 ' on change %s.' % (label, username, change))
892 jmsg = GetChangeCurrentRevision(host, change)
893 if not jmsg:
894 raise GerritError(
895 200, 'Could not get review information for change "%s"' % change)
896 elif jmsg[0]['current_revision'] != revision:
897 raise GerritError(200, 'While resetting labels on change "%s", '
898 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800899
900
dimu833c94c2017-01-18 17:36:15 -0800901def CreateGerritBranch(host, project, branch, commit):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000902 """Creates a new branch from given project and commit
903
dimu833c94c2017-01-18 17:36:15 -0800904 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
905
906 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000907 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -0800908 """
909 path = 'projects/%s/branches/%s' % (project, branch)
910 body = {'revision': commit}
911 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700912 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800913 if response:
914 return response
915 raise GerritError(200, 'Unable to create gerrit branch')
916
917
918def GetGerritBranch(host, project, branch):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000919 """Gets a branch from given project and commit.
920
921 See:
dimu833c94c2017-01-18 17:36:15 -0800922 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
923
924 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000925 A JSON object with 'revision' key.
dimu833c94c2017-01-18 17:36:15 -0800926 """
927 path = 'projects/%s/branches/%s' % (project, branch)
928 conn = CreateHttpConn(host, path, reqtype='GET')
929 response = ReadHttpJsonResponse(conn)
930 if response:
931 return response
932 raise GerritError(200, 'Unable to get gerrit branch')
933
934
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100935def GetAccountDetails(host, account_id='self'):
936 """Returns details of the account.
937
938 If account_id is not given, uses magic value 'self' which corresponds to
939 whichever account user is authenticating as.
940
941 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000942 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000943
944 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100945 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100946 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000947 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
948
949
950def ValidAccounts(host, accounts, max_threads=10):
951 """Returns a mapping from valid account to its details.
952
953 Invalid accounts, either not existing or without unique match,
954 are not present as returned dictionary keys.
955 """
956 assert not isinstance(accounts, basestring), type(accounts)
957 accounts = list(set(accounts))
958 if not accounts:
959 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000960
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000961 def get_one(account):
962 try:
963 return account, GetAccountDetails(host, account)
964 except GerritError:
965 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000966
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000967 valid = {}
968 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
969 for account, details in pool.map(get_one, accounts):
970 if account and details:
971 valid[account] = details
972 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100973
974
Nick Carter8692b182017-11-06 16:30:38 -0800975def PercentEncodeForGitRef(original):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000976 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -0800977
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000978 The encoding used is based on but stricter than URL encoding (Section 2.1 of
979 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
980 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -0800981
982 For more information, see the Gerrit docs here:
983
984 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
985 """
986 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
987 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
988
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000989 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
Nick Carter8692b182017-11-06 16:30:38 -0800990 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
991 return encoded.replace(' ', '_')
992
993
Dan Jacques8d11e482016-11-15 14:25:56 -0800994@contextlib.contextmanager
995def tempdir():
996 tdir = None
997 try:
998 tdir = tempfile.mkdtemp(suffix='gerrit_util')
999 yield tdir
1000 finally:
1001 if tdir:
1002 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001003
1004
1005def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +00001006 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001007 this module API.
1008
1009 Such format is allows for more efficient Gerrit routing of HTTP requests,
1010 comparing to specifying just change_number.
1011 """
1012 assert int(change_number)
1013 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001014
1015
1016# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001017_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001018assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1019
1020
1021def _UseGerritMirror(url, host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001022 """Returns a new URL which uses randomly selected mirror for a Gerrit host.
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001023
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001024 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 +00001025 function.
1026
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001027 Assumes that the URL has a single occurence of the host substring.
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001028 """
1029 assert host in url
1030 suffix = '-mirror-' + host
1031 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1032 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1033 st = url.find(suffix)
1034 if st == -1:
1035 actual_host = host
1036 else:
1037 # Already uses some mirror.
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001038 assert st >= prefix_len, (url, host, st, prefix_len)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001039 prefixes.remove(url[st-prefix_len:st])
1040 actual_host = url[st-prefix_len:st+len(suffix)]
1041 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)