blob: 24cc357ea5f30d130403e599fa05d77b37ea909b [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"""
6Utilities for requesting information for a gerrit server via https.
7
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
szager@chromium.orgb4696232013-10-16 19:45:35 +000047# Controls the transport protocol used to communicate with gerrit.
48# 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:
146 return ('Git host for gerrit upload is unknown. Check your remote '
147 '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
324
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000325 @classmethod
326 def _get_token_dict(cls):
327 if cls._token_cache:
328 # If it expires within 25 seconds, refresh.
329 if cls._token_expiration < time.time() - 25:
330 return cls._token_cache
331
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100332 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000333 if resp.status != httplib.OK:
334 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100335 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000336 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
337 return cls._token_cache
338
339 def get_auth_header(self, _host):
340 token_dict = self._get_token_dict()
341 if not token_dict:
342 return None
343 return '%(token_type)s %(access_token)s' % token_dict
344
345
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700346class LuciContextAuthenticator(Authenticator):
347 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
348 """
349
350 @staticmethod
351 def is_luci():
352 return auth.has_luci_context_local_auth()
353
354 def __init__(self):
355 self._access_token = None
356 self._ensure_fresh()
357
358 def _ensure_fresh(self):
359 if not self._access_token or self._access_token.needs_refresh():
360 self._access_token = auth.get_luci_context_access_token(
361 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
362
363 def get_auth_header(self, _host):
364 self._ensure_fresh()
365 return 'Bearer %s' % self._access_token.token
366
367
szager@chromium.orgb4696232013-10-16 19:45:35 +0000368def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
369 """Opens an https connection to a gerrit service, and sends a request."""
370 headers = headers or {}
371 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000372
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700373 a = Authenticator.get().get_auth_header(bare_host)
374 if a:
375 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000376 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000377 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000378
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800379 url = path
380 if not url.startswith('/'):
381 url = '/' + url
382 if 'Authorization' in headers and not url.startswith('/a/'):
383 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000384
szager@chromium.orgb4696232013-10-16 19:45:35 +0000385 if body:
386 body = json.JSONEncoder().encode(body)
387 headers.setdefault('Content-Type', 'application/json')
388 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000389 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000390 for key, val in headers.iteritems():
391 if key == 'Authorization':
392 val = 'HIDDEN'
393 LOGGER.debug('%s: %s' % (key, val))
394 if body:
395 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700396 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000397 # HACK: httplib.Http has no such attribute; we store req_host here for later
398 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000399 conn.req_host = host
400 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100401 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000402 'method': reqtype,
403 'headers': headers,
404 'body': body,
405 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000406 return conn
407
408
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700409def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000410 """Reads an http response from a connection into a string buffer.
411
412 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100413 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700414 accept_statuses: Treat any of these statuses as success. Default: [200]
415 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000416 Returns: A string buffer containing the connection's reply.
417 """
Steve Kobes56117722018-09-13 18:18:35 +0000418 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000419 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000420 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100421 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000422
Edward Lemur5a9ff432018-10-30 19:00:22 +0000423 response_time = time.time() - before_response
424 metrics.collector.add_repeated(
425 'http_requests',
426 metrics_utils.extract_http_metrics(
427 conn.req_params['uri'], conn.req_params['method'], response.status,
428 response_time))
429
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000430 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100431 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000432 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
433 www_authenticate):
434 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
435 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700436 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000437 'has credentials for %s' % host)
438 raise GerritAuthenticationError(response.status, reason)
439
szager@chromium.orgb4696232013-10-16 19:45:35 +0000440 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000441 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700442 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000443 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700444 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100445 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100446 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700447 # If 404 was in accept_statuses, then it's expected that the file might
448 # not exist, so don't return the gitiles error page because that's not the
449 # "content" that was actually requested.
450 if response.status == 404:
451 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000452 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000453 # A status >=500 is assumed to be a possible transient error; retry.
454 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
455 LOGGER.warn('A transient error occurred while querying %s:\n'
456 '%s %s %s\n'
457 '%s %d %s',
458 conn.req_host, conn.req_params['method'],
459 conn.req_params['uri'],
460 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000461 if response.status == 404:
462 # TODO(crbug/881860): remove this hack.
463 # HACK: try different Gerrit mirror as a workaround for potentially
464 # out-of-date mirror hit through default routing.
465 if conn.req_host == 'chromium-review.googlesource.com':
466 conn.req_params['uri'] = _UseGerritMirror(
467 conn.req_params['uri'], 'chromium-review.googlesource.com')
468 # And don't increase sleep_time in this case, since we suspect we've
469 # just asked wrong git mirror before.
470 sleep_time /= 2.0
471
szager@chromium.orgb4696232013-10-16 19:45:35 +0000472 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800473 LOGGER.info('Will retry in %d seconds (%d more times)...',
474 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000475 time.sleep(sleep_time)
476 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000477 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700478 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200479 if response.status in (401, 403):
480 print('Your Gerrit credentials might be misconfigured. Try: \n'
481 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100482 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000483 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100484 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000485
486
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700487def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000488 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700489 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000490 # The first line of the response should always be: )]}'
491 s = fh.readline()
492 if s and s.rstrip() != ")]}'":
493 raise GerritError(200, 'Unexpected json output: %s' % s)
494 s = fh.read()
495 if not s:
496 return None
497 return json.loads(s)
498
499
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200500def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100501 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000502 """
503 Queries a gerrit-on-borg server for changes matching query terms.
504
505 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200506 params: A list of key:value pairs for search parameters, as documented
507 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
508 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000509 first_param: A change identifier
510 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100511 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000512 o_params: A list of additional output specifiers, as documented here:
513 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
514 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):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000532 """
533 Queries a gerrit-on-borg server for all the changes matching the query terms.
534
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100535 WARNING: this is unreliable if a change matching the query is modified while
536 this function is being called.
537
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()
552 def at_most_once(cls):
553 for cl in cls:
554 if cl['_number'] not in already_returned:
555 already_returned.add(cl['_number'])
556 yield cl
557
558 start = start or 0
559 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000560 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100561
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000562 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100563 # This will fetch changes[start..start+limit] sorted by most recently
564 # updated. Since the rank of any change in this list can be changed any time
565 # (say user posting comment), subsequent calls may overalp like this:
566 # > initial order ABCDEFGH
567 # query[0..3] => ABC
568 # > E get's updated. New order: EABCDFGH
569 # query[3..6] => CDF # C is a dup
570 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200571 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100572 cur_start)
573 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000574 yield cl
575
576 more_changes = [cl for cl in page if '_more_changes' in cl]
577 if len(more_changes) > 1:
578 raise GerritError(
579 200,
580 'Received %d changes with a _more_changes attribute set but should '
581 'receive at most one.' % len(more_changes))
582 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100583 cur_start += len(page)
584
585 # If we paged through, query again the first page which in most circumstances
586 # will fetch all changes that were modified while this function was run.
587 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200588 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100589 for cl in at_most_once(page):
590 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000591
592
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200593def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100594 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000595 """Initiate a query composed of multiple sets of query parameters."""
596 if not change_list:
597 raise RuntimeError(
598 "MultiQueryChanges requires a list of change numbers/id's")
599 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200600 if params:
601 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000602 if limit:
603 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100604 if start:
605 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000606 if o_params:
607 q.extend(['o=%s' % p for p in o_params])
608 path = 'changes/?%s' % '&'.join(q)
609 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700610 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000611 except GerritError as e:
612 msg = '%s:\n%s' % (e.message, path)
613 raise GerritError(e.http_status, msg)
614 return result
615
616
617def GetGerritFetchUrl(host):
618 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
619 return '%s://%s/' % (GERRIT_PROTOCOL, host)
620
621
Edward Lemur687ca902018-12-05 02:30:30 +0000622def GetCodeReviewTbrScore(host, project):
623 """Given a gerrit host name and project, return the Code-Review score for TBR.
624 """
625 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
626 project = ReadHttpJsonResponse(conn)
627 if ('labels' not in project
628 or 'Code-Review' not in project['labels']
629 or 'values' not in project['labels']['Code-Review']):
630 return 1
631 return max([int(x) for x in project['labels']['Code-Review']['values']])
632
633
szager@chromium.orgb4696232013-10-16 19:45:35 +0000634def GetChangePageUrl(host, change_number):
635 """Given a gerrit host name and change number, return change page url."""
636 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
637
638
639def GetChangeUrl(host, change):
640 """Given a gerrit host name and change id, return an url for the change."""
641 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
642
643
644def GetChange(host, change):
645 """Query a gerrit server for information about a single change."""
646 path = 'changes/%s' % change
647 return ReadHttpJsonResponse(CreateHttpConn(host, path))
648
649
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700650def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000651 """Query a gerrit server for extended information about a single change."""
652 path = 'changes/%s/detail' % change
653 if o_params:
654 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700655 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000656
657
agable32978d92016-11-01 12:55:02 -0700658def GetChangeCommit(host, change, revision='current'):
659 """Query a gerrit server for a revision associated with a change."""
660 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
661 return ReadHttpJsonResponse(CreateHttpConn(host, path))
662
663
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664def GetChangeCurrentRevision(host, change):
665 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200666 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000667
668
669def GetChangeRevisions(host, change):
670 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200671 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000672
673
674def GetChangeReview(host, change, revision=None):
675 """Get the current review information for a change."""
676 if not revision:
677 jmsg = GetChangeRevisions(host, change)
678 if not jmsg:
679 return None
680 elif len(jmsg) > 1:
681 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
682 revision = jmsg[0]['current_revision']
683 path = 'changes/%s/revisions/%s/review'
684 return ReadHttpJsonResponse(CreateHttpConn(host, path))
685
686
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700687def GetChangeComments(host, change):
688 """Get the line- and file-level comments on a change."""
689 path = 'changes/%s/comments' % change
690 return ReadHttpJsonResponse(CreateHttpConn(host, path))
691
692
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000693def GetChangeRobotComments(host, change):
694 """Get the line- and file-level robot comments on a change."""
695 path = 'changes/%s/robotcomments' % change
696 return ReadHttpJsonResponse(CreateHttpConn(host, path))
697
698
szager@chromium.orgb4696232013-10-16 19:45:35 +0000699def AbandonChange(host, change, msg=''):
700 """Abandon a gerrit change."""
701 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000702 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000703 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700704 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000705
706
707def RestoreChange(host, change, msg=''):
708 """Restore a previously abandoned change."""
709 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000710 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000711 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700712 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000713
714
715def SubmitChange(host, change, wait_for_merge=True):
716 """Submits a gerrit change via Gerrit."""
717 path = 'changes/%s/submit' % change
718 body = {'wait_for_merge': wait_for_merge}
719 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700720 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000721
722
dsansomee2d6fd92016-09-08 00:10:47 -0700723def HasPendingChangeEdit(host, change):
724 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
725 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700726 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700727 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700728 # 204 No Content means no pending change.
729 if e.http_status == 204:
730 return False
731 raise
732 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700733
734
735def DeletePendingChangeEdit(host, change):
736 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700737 # On success, gerrit returns status 204; if the edit was already deleted it
738 # returns 404. Anything else is an error.
739 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700740
741
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100742def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000743 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700744 assert notify in ('ALL', 'NONE')
745 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700746 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700747 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000748 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700749 ReadHttpResponse(conn, accept_statuses=[200, 204])
750 except GerritError as e:
751 raise GerritError(
752 e.http_status,
753 'Received unexpected http status while editing message '
754 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000755
756
szager@chromium.orgb4696232013-10-16 19:45:35 +0000757def GetReviewers(host, change):
758 """Get information about all reviewers attached to a change."""
759 path = 'changes/%s/reviewers' % change
760 return ReadHttpJsonResponse(CreateHttpConn(host, path))
761
762
763def GetReview(host, change, revision):
764 """Get review information about a specific revision of a change."""
765 path = 'changes/%s/revisions/%s/review' % (change, revision)
766 return ReadHttpJsonResponse(CreateHttpConn(host, path))
767
768
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700769def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
770 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000771 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700772 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800773 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200774 if not change:
775 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700776 reviewers = frozenset(reviewers or [])
777 ccs = frozenset(ccs or [])
778 path = 'changes/%s/revisions/current/review' % change
779
780 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800781 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700782 'reviewers': [],
783 'notify': 'ALL' if notify else 'NONE',
784 }
785 for r in sorted(reviewers | ccs):
786 state = 'REVIEWER' if r in reviewers else 'CC'
787 body['reviewers'].append({
788 'reviewer': r,
789 'state': state,
790 'notify': 'NONE', # We handled `notify` argument above.
Raul Tambre80ee78e2019-05-06 22:41:05 +0000791 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700792
793 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
794 # Gerrit will return 400 if one or more of the requested reviewers are
795 # unprocessable. We read the response object to see which were rejected,
796 # warn about them, and retry with the remainder.
797 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
798
799 errored = set()
800 for result in resp.get('reviewers', {}).itervalues():
801 r = result.get('input')
802 state = 'REVIEWER' if r in reviewers else 'CC'
803 if result.get('error'):
804 errored.add(r)
805 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
806 if errored:
807 # Try again, adding only those that didn't fail, and only accepting 200.
808 AddReviewers(host, change, reviewers=(reviewers-errored),
809 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000810
811
812def RemoveReviewers(host, change, remove=None):
813 """Remove reveiewers from a change."""
814 if not remove:
815 return
816 if isinstance(remove, basestring):
817 remove = (remove,)
818 for r in remove:
819 path = 'changes/%s/reviewers/%s' % (change, r)
820 conn = CreateHttpConn(host, path, reqtype='DELETE')
821 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700822 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000823 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000824 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700825 e.http_status,
826 'Received unexpected http status while deleting reviewer "%s" '
827 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000828
829
Aaron Gable636b13f2017-07-14 10:42:48 -0700830def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000831 """Set labels and/or add a message to a code review."""
832 if not msg and not labels:
833 return
834 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800835 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000836 if msg:
837 body['message'] = msg
838 if labels:
839 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700840 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700841 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700842 if ready:
843 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000844 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
845 response = ReadHttpJsonResponse(conn)
846 if labels:
847 for key, val in labels.iteritems():
848 if ('labels' not in response or key not in response['labels'] or
849 int(response['labels'][key] != int(val))):
850 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
851 key, change))
852
853
854def ResetReviewLabels(host, change, label, value='0', message=None,
855 notify=None):
856 """Reset the value of a given label for all reviewers on a change."""
857 # This is tricky, because we want to work on the "current revision", but
858 # there's always the risk that "current revision" will change in between
859 # API calls. So, we check "current revision" at the beginning and end; if
860 # it has changed, raise an exception.
861 jmsg = GetChangeCurrentRevision(host, change)
862 if not jmsg:
863 raise GerritError(
864 200, 'Could not get review information for change "%s"' % change)
865 value = str(value)
866 revision = jmsg[0]['current_revision']
867 path = 'changes/%s/revisions/%s/review' % (change, revision)
868 message = message or (
869 '%s label set to %s programmatically.' % (label, value))
870 jmsg = GetReview(host, change, revision)
871 if not jmsg:
872 raise GerritError(200, 'Could not get review information for revison %s '
873 'of change %s' % (revision, change))
874 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
875 if str(review.get('value', value)) != value:
876 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800877 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000878 'message': message,
879 'labels': {label: value},
880 'on_behalf_of': review['_account_id'],
881 }
882 if notify:
883 body['notify'] = notify
884 conn = CreateHttpConn(
885 host, path, reqtype='POST', body=body)
886 response = ReadHttpJsonResponse(conn)
887 if str(response['labels'][label]) != value:
888 username = review.get('email', jmsg.get('name', ''))
889 raise GerritError(200, 'Unable to set %s label for user "%s"'
890 ' on change %s.' % (label, username, change))
891 jmsg = GetChangeCurrentRevision(host, change)
892 if not jmsg:
893 raise GerritError(
894 200, 'Could not get review information for change "%s"' % change)
895 elif jmsg[0]['current_revision'] != revision:
896 raise GerritError(200, 'While resetting labels on change "%s", '
897 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800898
899
dimu833c94c2017-01-18 17:36:15 -0800900def CreateGerritBranch(host, project, branch, commit):
901 """
902 Create a new branch from given project and commit
903 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
904
905 Returns:
906 A JSON with 'ref' key
907 """
908 path = 'projects/%s/branches/%s' % (project, branch)
909 body = {'revision': commit}
910 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700911 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800912 if response:
913 return response
914 raise GerritError(200, 'Unable to create gerrit branch')
915
916
917def GetGerritBranch(host, project, branch):
918 """
919 Get a branch from given project and commit
920 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
921
922 Returns:
923 A JSON object with 'revision' key
924 """
925 path = 'projects/%s/branches/%s' % (project, branch)
926 conn = CreateHttpConn(host, path, reqtype='GET')
927 response = ReadHttpJsonResponse(conn)
928 if response:
929 return response
930 raise GerritError(200, 'Unable to get gerrit branch')
931
932
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100933def GetAccountDetails(host, account_id='self'):
934 """Returns details of the account.
935
936 If account_id is not given, uses magic value 'self' which corresponds to
937 whichever account user is authenticating as.
938
939 Documentation:
940 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000941
942 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100943 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100944 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000945 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
946
947
948def ValidAccounts(host, accounts, max_threads=10):
949 """Returns a mapping from valid account to its details.
950
951 Invalid accounts, either not existing or without unique match,
952 are not present as returned dictionary keys.
953 """
954 assert not isinstance(accounts, basestring), type(accounts)
955 accounts = list(set(accounts))
956 if not accounts:
957 return {}
958 def get_one(account):
959 try:
960 return account, GetAccountDetails(host, account)
961 except GerritError:
962 return None, None
963 valid = {}
964 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
965 for account, details in pool.map(get_one, accounts):
966 if account and details:
967 valid[account] = details
968 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100969
970
Nick Carter8692b182017-11-06 16:30:38 -0800971def PercentEncodeForGitRef(original):
972 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
973
974 The encoding used is based on but stricter than URL encoding (Section 2.1
975 of RFC 3986). The only non-escaped characters are alphanumerics, and
976 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
977 'PLUS SIGN' (U+002B).
978
979 For more information, see the Gerrit docs here:
980
981 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
982 """
983 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
984 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
985
986 # spaces are not allowed in git refs; gerrit will interpret either '_' or
987 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
988 return encoded.replace(' ', '_')
989
990
Dan Jacques8d11e482016-11-15 14:25:56 -0800991@contextlib.contextmanager
992def tempdir():
993 tdir = None
994 try:
995 tdir = tempfile.mkdtemp(suffix='gerrit_util')
996 yield tdir
997 finally:
998 if tdir:
999 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001000
1001
1002def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +00001003 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001004 this module API.
1005
1006 Such format is allows for more efficient Gerrit routing of HTTP requests,
1007 comparing to specifying just change_number.
1008 """
1009 assert int(change_number)
1010 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001011
1012
1013# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001014_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001015assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1016
1017
1018def _UseGerritMirror(url, host):
1019 """Returns new url which uses randomly selected mirror for a gerrit host.
1020
1021 url's host should be for a given host or a result of prior call to this
1022 function.
1023
1024 Assumes url has a single occurence of the host substring.
1025 """
1026 assert host in url
1027 suffix = '-mirror-' + host
1028 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1029 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1030 st = url.find(suffix)
1031 if st == -1:
1032 actual_host = host
1033 else:
1034 # Already uses some mirror.
1035 assert st >= prefix_len, (uri, host, st, prefix_len)
1036 prefixes.remove(url[st-prefix_len:st])
1037 actual_host = url[st-prefix_len:st+len(suffix)]
1038 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)