blob: ef9b1022e86d9f950bfcc9c18334c42bf5ef5d4b [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
11import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080012import contextlib
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000013import cookielib
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010014import httplib # Still used for its constants.
szager@chromium.orgb4696232013-10-16 19:45:35 +000015import json
16import logging
17import netrc
18import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000019import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000020import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000021import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000022import stat
23import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080024import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000025import time
26import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000027import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000028from cStringIO import StringIO
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000029from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000030
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070031import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080032import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000033import metrics
34import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080035import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010036from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000037
szager@chromium.orgb4696232013-10-16 19:45:35 +000038LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000039# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
40# total tries, the sleep time between the first and last tries will be 94.5 sec.
41# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
42TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000043
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000044
szager@chromium.orgb4696232013-10-16 19:45:35 +000045# Controls the transport protocol used to communicate with gerrit.
46# This is parameterized primarily to enable GerritTestCase.
47GERRIT_PROTOCOL = 'https'
48
49
Edward Lemur83bd7f42018-10-10 00:14:21 +000050# TODO(crbug.com/881860): Remove.
51GERRIT_ERR_LOGGER = logging.getLogger('GerritErrorLogs')
52GERRIT_ERR_LOG_FILE = os.path.join(tempfile.gettempdir(), 'GerritHeaders.txt')
Edward Lemur47faa062018-10-11 19:46:02 +000053GERRIT_ERR_MESSAGE = (
54 'If you see this when running \'git cl upload\', please report this to '
55 'https://crbug.com/881860, and attach the failures in %s.\n' %
56 GERRIT_ERR_LOG_FILE)
Edward Lemur83bd7f42018-10-10 00:14:21 +000057INTERESTING_HEADERS = frozenset([
58 'x-google-backends',
59 'x-google-errorfiltertrace',
60 'x-google-filter-grace',
61 'x-errorid',
62])
63
64
szager@chromium.orgb4696232013-10-16 19:45:35 +000065class GerritError(Exception):
66 """Exception class for errors commuicating with the gerrit-on-borg service."""
67 def __init__(self, http_status, *args, **kwargs):
68 super(GerritError, self).__init__(*args, **kwargs)
69 self.http_status = http_status
70 self.message = '(%d) %s' % (self.http_status, self.message)
71
72
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000073class GerritAuthenticationError(GerritError):
74 """Exception class for authentication errors during Gerrit communication."""
75
76
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020077def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000078 """Encodes query parameters in the key:val[+key:val...] format specified here:
79
80 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
81 """
82 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020083 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000084 return '+'.join(q)
85
86
Aaron Gabled2db5a22017-03-24 14:14:15 -070087def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000088 if protocol is None:
89 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010090 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070091 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000092 else:
93 raise RuntimeError(
94 "Don't know how to work with protocol '%s'" % protocol)
95
96
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000097class Authenticator(object):
98 """Base authenticator class for authenticator implementations to subclass."""
99
100 def get_auth_header(self, host):
101 raise NotImplementedError()
102
103 @staticmethod
104 def get():
105 """Returns: (Authenticator) The identified Authenticator to use.
106
107 Probes the local system and its environment and identifies the
108 Authenticator instance to use.
109 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700110 # LUCI Context takes priority since it's normally present only on bots,
111 # which then must use it.
112 if LuciContextAuthenticator.is_luci():
113 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000114 if GceAuthenticator.is_gce():
115 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000116 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000117
118
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000119class CookiesAuthenticator(Authenticator):
120 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
121
122 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000123 """
124
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000125 _EMPTY = object()
126
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000127 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000128 # Credentials will be loaded lazily on first use. This ensures Authenticator
129 # get() can always construct an authenticator, even if something is broken.
130 # This allows 'creds-check' to proceed to actually checking creds later,
131 # rigorously (instead of blowing up with a cryptic error if they are wrong).
132 self._netrc = self._EMPTY
133 self._gitcookies = self._EMPTY
134
135 @property
136 def netrc(self):
137 if self._netrc is self._EMPTY:
138 self._netrc = self._get_netrc()
139 return self._netrc
140
141 @property
142 def gitcookies(self):
143 if self._gitcookies is self._EMPTY:
144 self._gitcookies = self._get_gitcookies()
145 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000146
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000147 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200148 def get_new_password_url(cls, host):
149 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 return 'https://%s/new-password' % ('.'.join(parts))
155
156 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000157 def get_new_password_message(cls, host):
158 assert not host.startswith('http')
159 # Assume *.googlesource.com pattern.
160 parts = host.split('.')
161 if not parts[0].endswith('-review'):
162 parts[0] += '-review'
163 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100164 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000165
166 @classmethod
167 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000168 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000169 return os.path.expanduser(os.path.join('~', path))
170
171 @classmethod
172 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800173 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000174 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000175 if not os.path.exists(path):
176 return netrc.netrc(os.devnull)
177
178 st = os.stat(path)
179 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
180 print >> sys.stderr, (
181 'WARNING: netrc file %s cannot be used because its file '
182 'permissions are insecure. netrc file permissions should be '
183 '600.' % path)
184 with open(path) as fd:
185 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800186
187 # Load the '.netrc' file. We strip comments from it because processing them
188 # can trigger a bug in Windows. See crbug.com/664664.
189 content = '\n'.join(l for l in content.splitlines()
190 if l.strip() and not l.strip().startswith('#'))
191 with tempdir() as tdir:
192 netrc_path = os.path.join(tdir, 'netrc')
193 with open(netrc_path, 'w') as fd:
194 fd.write(content)
195 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
196 return cls._get_netrc_from_path(netrc_path)
197
198 @classmethod
199 def _get_netrc_from_path(cls, path):
200 try:
201 return netrc.netrc(path)
202 except IOError:
203 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
204 return netrc.netrc(os.devnull)
205 except netrc.NetrcParseError as e:
206 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
207 'parsing error: %s' % (path, e))
208 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000209
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000210 @classmethod
211 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500212 if os.getenv('GIT_COOKIES_PATH'):
213 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800214 try:
215 return subprocess2.check_output(
216 ['git', 'config', '--path', 'http.cookiefile']).strip()
217 except subprocess2.CalledProcessError:
218 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000219
220 @classmethod
221 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000222 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000223 path = cls.get_gitcookies_path()
224 if not os.path.exists(path):
225 return gitcookies
226
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000227 try:
228 f = open(path, 'rb')
229 except IOError:
230 return gitcookies
231
232 with f:
233 for line in f:
234 try:
235 fields = line.strip().split('\t')
236 if line.strip().startswith('#') or len(fields) != 7:
237 continue
238 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
239 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000240 if value.startswith('git-'):
241 login, secret_token = value.split('=', 1)
242 gitcookies[domain] = (login, secret_token)
243 else:
244 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000245 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100246 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000247 return gitcookies
248
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100249 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000250 for domain, creds in self.gitcookies.iteritems():
251 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100252 return (creds[0], None, creds[1])
253 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000254
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100255 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700256 a = self._get_auth_for_host(host)
257 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000258 if a[0]:
259 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
260 else:
261 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000262 return None
263
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100264 def get_auth_email(self, host):
265 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700266 a = self._get_auth_for_host(host)
267 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100268 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700269 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100270 # login typically looks like 'git-xxx.example.com'
271 if not login.startswith('git-') or '.' not in login:
272 return None
273 username, domain = login[len('git-'):].split('.', 1)
274 return '%s@%s' % (username, domain)
275
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100276
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000277# Backwards compatibility just in case somebody imports this outside of
278# depot_tools.
279NetrcAuthenticator = CookiesAuthenticator
280
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000281
282class GceAuthenticator(Authenticator):
283 """Authenticator implementation that uses GCE metadata service for token.
284 """
285
286 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700287 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
288 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000289 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
290
291 _cache_is_gce = None
292 _token_cache = None
293 _token_expiration = None
294
295 @classmethod
296 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500297 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
298 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000299 if cls._cache_is_gce is None:
300 cls._cache_is_gce = cls._test_is_gce()
301 return cls._cache_is_gce
302
303 @classmethod
304 def _test_is_gce(cls):
305 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
306 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100307 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100308 except (socket.error, httplib2.ServerNotFoundError,
309 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000310 # Could not resolve URL.
311 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100312 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000313
314 @staticmethod
315 def _get(url, **kwargs):
316 next_delay_sec = 1
317 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000318 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700319 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100320 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000321 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
322 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100323 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000324
Aaron Gable92e9f382017-12-07 11:47:41 -0800325 # Retry server error status codes.
326 LOGGER.warn('Encountered server error')
327 if TRY_LIMIT - i > 1:
328 LOGGER.info('Will retry in %d seconds (%d more times)...',
329 next_delay_sec, TRY_LIMIT - i - 1)
330 time.sleep(next_delay_sec)
331 next_delay_sec *= 2
332
333
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000334 @classmethod
335 def _get_token_dict(cls):
336 if cls._token_cache:
337 # If it expires within 25 seconds, refresh.
338 if cls._token_expiration < time.time() - 25:
339 return cls._token_cache
340
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100341 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000342 if resp.status != httplib.OK:
343 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100344 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000345 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
346 return cls._token_cache
347
348 def get_auth_header(self, _host):
349 token_dict = self._get_token_dict()
350 if not token_dict:
351 return None
352 return '%(token_type)s %(access_token)s' % token_dict
353
354
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700355class LuciContextAuthenticator(Authenticator):
356 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
357 """
358
359 @staticmethod
360 def is_luci():
361 return auth.has_luci_context_local_auth()
362
363 def __init__(self):
364 self._access_token = None
365 self._ensure_fresh()
366
367 def _ensure_fresh(self):
368 if not self._access_token or self._access_token.needs_refresh():
369 self._access_token = auth.get_luci_context_access_token(
370 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
371
372 def get_auth_header(self, _host):
373 self._ensure_fresh()
374 return 'Bearer %s' % self._access_token.token
375
376
szager@chromium.orgb4696232013-10-16 19:45:35 +0000377def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
378 """Opens an https connection to a gerrit service, and sends a request."""
379 headers = headers or {}
380 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000381
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700382 a = Authenticator.get().get_auth_header(bare_host)
383 if a:
384 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000385 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000386 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000387
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800388 url = path
389 if not url.startswith('/'):
390 url = '/' + url
391 if 'Authorization' in headers and not url.startswith('/a/'):
392 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000393
szager@chromium.orgb4696232013-10-16 19:45:35 +0000394 if body:
395 body = json.JSONEncoder().encode(body)
396 headers.setdefault('Content-Type', 'application/json')
397 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000398 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000399 for key, val in headers.iteritems():
400 if key == 'Authorization':
401 val = 'HIDDEN'
402 LOGGER.debug('%s: %s' % (key, val))
403 if body:
404 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700405 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000406 # HACK: httplib.Http has no such attribute; we store req_host here for later
407 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000408 conn.req_host = host
409 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100410 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000411 'method': reqtype,
412 'headers': headers,
413 'body': body,
414 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000415 return conn
416
417
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700418def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000419 """Reads an http response from a connection into a string buffer.
420
421 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100422 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700423 accept_statuses: Treat any of these statuses as success. Default: [200]
424 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000425 Returns: A string buffer containing the connection's reply.
426 """
Steve Kobes56117722018-09-13 18:18:35 +0000427 sleep_time = 1.5
Edward Lemur83bd7f42018-10-10 00:14:21 +0000428 failed = False
szager@chromium.orgb4696232013-10-16 19:45:35 +0000429 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000430 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100431 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000432
Edward Lemur5a9ff432018-10-30 19:00:22 +0000433 response_time = time.time() - before_response
434 metrics.collector.add_repeated(
435 'http_requests',
436 metrics_utils.extract_http_metrics(
437 conn.req_params['uri'], conn.req_params['method'], response.status,
438 response_time))
439
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000440 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100441 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000442 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
443 www_authenticate):
444 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
445 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700446 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000447 'has credentials for %s' % host)
448 raise GerritAuthenticationError(response.status, reason)
449
szager@chromium.orgb4696232013-10-16 19:45:35 +0000450 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000451 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700452 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000453 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700454 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100455 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100456 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700457 # If 404 was in accept_statuses, then it's expected that the file might
458 # not exist, so don't return the gitiles error page because that's not the
459 # "content" that was actually requested.
460 if response.status == 404:
461 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000462 break
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000463 if response.status == 404:
464 # TODO(crbug/881860): remove this hack.
465 # HACK: try different Gerrit mirror as a workaround for potentially
466 # out-of-date mirror hit through default routing.
467 if conn.req_host == 'chromium-review.googlesource.com':
468 conn.req_params['uri'] = _UseGerritMirror(
469 conn.req_params['uri'], 'chromium-review.googlesource.com')
470 # And don't increase sleep_time in this case, since we suspect we've
471 # just asked wrong git mirror before.
472 sleep_time /= 2.0
Edward Lemur83bd7f42018-10-10 00:14:21 +0000473 failed = True
474 rpc_headers = '\n'.join(
475 ' ' + header + ': ' + value
476 for header, value in response.iteritems()
477 if header.lower() in INTERESTING_HEADERS
478 )
Edward Lemurb0b43f32018-10-16 22:49:27 +0000479 GERRIT_ERR_LOGGER.info(
480 'Gerrit RPC failure headers:\n'
481 ' Host: %s\n'
482 ' Ip: %s\n'
483 '%s\n',
484 conn.connections.values()[0].host,
485 conn.connections.values()[0].sock.getpeername(),
486 rpc_headers)
Edward Lemur83bd7f42018-10-10 00:14:21 +0000487 else:
488 # A status >=500 is assumed to be a possible transient error; retry.
489 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
490 LOGGER.warn('A transient error occurred while querying %s:\n'
491 '%s %s %s\n'
492 '%s %d %s',
493 conn.req_host, conn.req_params['method'],
494 conn.req_params['uri'],
495 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000496
szager@chromium.orgb4696232013-10-16 19:45:35 +0000497 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800498 LOGGER.info('Will retry in %d seconds (%d more times)...',
499 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000500 time.sleep(sleep_time)
501 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000502 # end of retries loop
503
504 if failed:
Edward Lemur47faa062018-10-11 19:46:02 +0000505 LOGGER.warn(GERRIT_ERR_MESSAGE)
Aaron Gable19ee16c2017-04-18 11:56:35 -0700506 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200507 if response.status in (401, 403):
508 print('Your Gerrit credentials might be misconfigured. Try: \n'
509 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100510 reason = '%s: %s' % (response.reason, contents)
Edward Lemur47faa062018-10-11 19:46:02 +0000511 if failed:
512 reason += '\n' + GERRIT_ERR_MESSAGE
nodir@chromium.orga7798032014-04-30 23:40:53 +0000513 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100514 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000515
516
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700517def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000518 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700519 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000520 # The first line of the response should always be: )]}'
521 s = fh.readline()
522 if s and s.rstrip() != ")]}'":
523 raise GerritError(200, 'Unexpected json output: %s' % s)
524 s = fh.read()
525 if not s:
526 return None
527 return json.loads(s)
528
529
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200530def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000532 """
533 Queries a gerrit-on-borg server for changes matching query terms.
534
535 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200536 params: A list of key:value pairs for search parameters, as documented
537 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
538 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000539 first_param: A change identifier
540 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100541 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000542 o_params: A list of additional output specifiers, as documented here:
543 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
544 Returns:
545 A list of json-decoded query results.
546 """
547 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200548 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000549 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200550 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100551 if start:
552 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000553 if limit:
554 path = '%s&n=%d' % (path, limit)
555 if o_params:
556 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700557 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000558
559
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200560def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100561 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000562 """
563 Queries a gerrit-on-borg server for all the changes matching the query terms.
564
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100565 WARNING: this is unreliable if a change matching the query is modified while
566 this function is being called.
567
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000568 A single query to gerrit-on-borg is limited on the number of results by the
569 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100570 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000571
572 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200573 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000574 limit: Maximum number of requested changes per query.
575 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100576 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000577
578 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100579 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000580 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100581 already_returned = set()
582 def at_most_once(cls):
583 for cl in cls:
584 if cl['_number'] not in already_returned:
585 already_returned.add(cl['_number'])
586 yield cl
587
588 start = start or 0
589 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000590 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100591
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000592 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100593 # This will fetch changes[start..start+limit] sorted by most recently
594 # updated. Since the rank of any change in this list can be changed any time
595 # (say user posting comment), subsequent calls may overalp like this:
596 # > initial order ABCDEFGH
597 # query[0..3] => ABC
598 # > E get's updated. New order: EABCDFGH
599 # query[3..6] => CDF # C is a dup
600 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200601 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100602 cur_start)
603 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000604 yield cl
605
606 more_changes = [cl for cl in page if '_more_changes' in cl]
607 if len(more_changes) > 1:
608 raise GerritError(
609 200,
610 'Received %d changes with a _more_changes attribute set but should '
611 'receive at most one.' % len(more_changes))
612 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100613 cur_start += len(page)
614
615 # If we paged through, query again the first page which in most circumstances
616 # will fetch all changes that were modified while this function was run.
617 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200618 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100619 for cl in at_most_once(page):
620 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000621
622
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200623def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100624 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000625 """Initiate a query composed of multiple sets of query parameters."""
626 if not change_list:
627 raise RuntimeError(
628 "MultiQueryChanges requires a list of change numbers/id's")
629 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200630 if params:
631 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000632 if limit:
633 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100634 if start:
635 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000636 if o_params:
637 q.extend(['o=%s' % p for p in o_params])
638 path = 'changes/?%s' % '&'.join(q)
639 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700640 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000641 except GerritError as e:
642 msg = '%s:\n%s' % (e.message, path)
643 raise GerritError(e.http_status, msg)
644 return result
645
646
647def GetGerritFetchUrl(host):
648 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
649 return '%s://%s/' % (GERRIT_PROTOCOL, host)
650
651
652def GetChangePageUrl(host, change_number):
653 """Given a gerrit host name and change number, return change page url."""
654 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
655
656
657def GetChangeUrl(host, change):
658 """Given a gerrit host name and change id, return an url for the change."""
659 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
660
661
662def GetChange(host, change):
663 """Query a gerrit server for information about a single change."""
664 path = 'changes/%s' % change
665 return ReadHttpJsonResponse(CreateHttpConn(host, path))
666
667
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700668def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000669 """Query a gerrit server for extended information about a single change."""
670 path = 'changes/%s/detail' % change
671 if o_params:
672 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700673 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000674
675
agable32978d92016-11-01 12:55:02 -0700676def GetChangeCommit(host, change, revision='current'):
677 """Query a gerrit server for a revision associated with a change."""
678 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
679 return ReadHttpJsonResponse(CreateHttpConn(host, path))
680
681
szager@chromium.orgb4696232013-10-16 19:45:35 +0000682def GetChangeCurrentRevision(host, change):
683 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200684 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000685
686
687def GetChangeRevisions(host, change):
688 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200689 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000690
691
692def GetChangeReview(host, change, revision=None):
693 """Get the current review information for a change."""
694 if not revision:
695 jmsg = GetChangeRevisions(host, change)
696 if not jmsg:
697 return None
698 elif len(jmsg) > 1:
699 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
700 revision = jmsg[0]['current_revision']
701 path = 'changes/%s/revisions/%s/review'
702 return ReadHttpJsonResponse(CreateHttpConn(host, path))
703
704
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700705def GetChangeComments(host, change):
706 """Get the line- and file-level comments on a change."""
707 path = 'changes/%s/comments' % change
708 return ReadHttpJsonResponse(CreateHttpConn(host, path))
709
710
szager@chromium.orgb4696232013-10-16 19:45:35 +0000711def AbandonChange(host, change, msg=''):
712 """Abandon a gerrit change."""
713 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000714 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000715 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700716 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000717
718
719def RestoreChange(host, change, msg=''):
720 """Restore a previously abandoned change."""
721 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000722 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000723 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700724 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000725
726
727def SubmitChange(host, change, wait_for_merge=True):
728 """Submits a gerrit change via Gerrit."""
729 path = 'changes/%s/submit' % change
730 body = {'wait_for_merge': wait_for_merge}
731 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700732 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000733
734
dsansomee2d6fd92016-09-08 00:10:47 -0700735def HasPendingChangeEdit(host, change):
736 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
737 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700738 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700739 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700740 # 204 No Content means no pending change.
741 if e.http_status == 204:
742 return False
743 raise
744 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700745
746
747def DeletePendingChangeEdit(host, change):
748 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700749 # On success, gerrit returns status 204; if the edit was already deleted it
750 # returns 404. Anything else is an error.
751 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700752
753
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100754def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000755 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700756 assert notify in ('ALL', 'NONE')
757 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700758 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700759 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000760 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700761 ReadHttpResponse(conn, accept_statuses=[200, 204])
762 except GerritError as e:
763 raise GerritError(
764 e.http_status,
765 'Received unexpected http status while editing message '
766 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000767
768
szager@chromium.orgb4696232013-10-16 19:45:35 +0000769def GetReviewers(host, change):
770 """Get information about all reviewers attached to a change."""
771 path = 'changes/%s/reviewers' % change
772 return ReadHttpJsonResponse(CreateHttpConn(host, path))
773
774
775def GetReview(host, change, revision):
776 """Get review information about a specific revision of a change."""
777 path = 'changes/%s/revisions/%s/review' % (change, revision)
778 return ReadHttpJsonResponse(CreateHttpConn(host, path))
779
780
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700781def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
782 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000783 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700784 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800785 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200786 if not change:
787 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700788 reviewers = frozenset(reviewers or [])
789 ccs = frozenset(ccs or [])
790 path = 'changes/%s/revisions/current/review' % change
791
792 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800793 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700794 'reviewers': [],
795 'notify': 'ALL' if notify else 'NONE',
796 }
797 for r in sorted(reviewers | ccs):
798 state = 'REVIEWER' if r in reviewers else 'CC'
799 body['reviewers'].append({
800 'reviewer': r,
801 'state': state,
802 'notify': 'NONE', # We handled `notify` argument above.
803 })
804
805 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
806 # Gerrit will return 400 if one or more of the requested reviewers are
807 # unprocessable. We read the response object to see which were rejected,
808 # warn about them, and retry with the remainder.
809 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
810
811 errored = set()
812 for result in resp.get('reviewers', {}).itervalues():
813 r = result.get('input')
814 state = 'REVIEWER' if r in reviewers else 'CC'
815 if result.get('error'):
816 errored.add(r)
817 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
818 if errored:
819 # Try again, adding only those that didn't fail, and only accepting 200.
820 AddReviewers(host, change, reviewers=(reviewers-errored),
821 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000822
823
824def RemoveReviewers(host, change, remove=None):
825 """Remove reveiewers from a change."""
826 if not remove:
827 return
828 if isinstance(remove, basestring):
829 remove = (remove,)
830 for r in remove:
831 path = 'changes/%s/reviewers/%s' % (change, r)
832 conn = CreateHttpConn(host, path, reqtype='DELETE')
833 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700834 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000835 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000836 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700837 e.http_status,
838 'Received unexpected http status while deleting reviewer "%s" '
839 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000840
841
Aaron Gable636b13f2017-07-14 10:42:48 -0700842def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000843 """Set labels and/or add a message to a code review."""
844 if not msg and not labels:
845 return
846 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800847 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000848 if msg:
849 body['message'] = msg
850 if labels:
851 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700852 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700853 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700854 if ready:
855 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000856 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
857 response = ReadHttpJsonResponse(conn)
858 if labels:
859 for key, val in labels.iteritems():
860 if ('labels' not in response or key not in response['labels'] or
861 int(response['labels'][key] != int(val))):
862 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
863 key, change))
864
865
866def ResetReviewLabels(host, change, label, value='0', message=None,
867 notify=None):
868 """Reset the value of a given label for all reviewers on a change."""
869 # This is tricky, because we want to work on the "current revision", but
870 # there's always the risk that "current revision" will change in between
871 # API calls. So, we check "current revision" at the beginning and end; if
872 # it has changed, raise an exception.
873 jmsg = GetChangeCurrentRevision(host, change)
874 if not jmsg:
875 raise GerritError(
876 200, 'Could not get review information for change "%s"' % change)
877 value = str(value)
878 revision = jmsg[0]['current_revision']
879 path = 'changes/%s/revisions/%s/review' % (change, revision)
880 message = message or (
881 '%s label set to %s programmatically.' % (label, value))
882 jmsg = GetReview(host, change, revision)
883 if not jmsg:
884 raise GerritError(200, 'Could not get review information for revison %s '
885 'of change %s' % (revision, change))
886 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
887 if str(review.get('value', value)) != value:
888 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800889 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000890 'message': message,
891 'labels': {label: value},
892 'on_behalf_of': review['_account_id'],
893 }
894 if notify:
895 body['notify'] = notify
896 conn = CreateHttpConn(
897 host, path, reqtype='POST', body=body)
898 response = ReadHttpJsonResponse(conn)
899 if str(response['labels'][label]) != value:
900 username = review.get('email', jmsg.get('name', ''))
901 raise GerritError(200, 'Unable to set %s label for user "%s"'
902 ' on change %s.' % (label, username, change))
903 jmsg = GetChangeCurrentRevision(host, change)
904 if not jmsg:
905 raise GerritError(
906 200, 'Could not get review information for change "%s"' % change)
907 elif jmsg[0]['current_revision'] != revision:
908 raise GerritError(200, 'While resetting labels on change "%s", '
909 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800910
911
dimu833c94c2017-01-18 17:36:15 -0800912def CreateGerritBranch(host, project, branch, commit):
913 """
914 Create a new branch from given project and commit
915 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
916
917 Returns:
918 A JSON with 'ref' key
919 """
920 path = 'projects/%s/branches/%s' % (project, branch)
921 body = {'revision': commit}
922 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700923 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800924 if response:
925 return response
926 raise GerritError(200, 'Unable to create gerrit branch')
927
928
929def GetGerritBranch(host, project, branch):
930 """
931 Get a branch from given project and commit
932 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
933
934 Returns:
935 A JSON object with 'revision' key
936 """
937 path = 'projects/%s/branches/%s' % (project, branch)
938 conn = CreateHttpConn(host, path, reqtype='GET')
939 response = ReadHttpJsonResponse(conn)
940 if response:
941 return response
942 raise GerritError(200, 'Unable to get gerrit branch')
943
944
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100945def GetAccountDetails(host, account_id='self'):
946 """Returns details of the account.
947
948 If account_id is not given, uses magic value 'self' which corresponds to
949 whichever account user is authenticating as.
950
951 Documentation:
952 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000953
954 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100955 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100956 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000957 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
958
959
960def ValidAccounts(host, accounts, max_threads=10):
961 """Returns a mapping from valid account to its details.
962
963 Invalid accounts, either not existing or without unique match,
964 are not present as returned dictionary keys.
965 """
966 assert not isinstance(accounts, basestring), type(accounts)
967 accounts = list(set(accounts))
968 if not accounts:
969 return {}
970 def get_one(account):
971 try:
972 return account, GetAccountDetails(host, account)
973 except GerritError:
974 return None, None
975 valid = {}
976 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
977 for account, details in pool.map(get_one, accounts):
978 if account and details:
979 valid[account] = details
980 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100981
982
Nick Carter8692b182017-11-06 16:30:38 -0800983def PercentEncodeForGitRef(original):
984 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
985
986 The encoding used is based on but stricter than URL encoding (Section 2.1
987 of RFC 3986). The only non-escaped characters are alphanumerics, and
988 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
989 'PLUS SIGN' (U+002B).
990
991 For more information, see the Gerrit docs here:
992
993 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
994 """
995 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
996 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
997
998 # spaces are not allowed in git refs; gerrit will interpret either '_' or
999 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
1000 return encoded.replace(' ', '_')
1001
1002
Dan Jacques8d11e482016-11-15 14:25:56 -08001003@contextlib.contextmanager
1004def tempdir():
1005 tdir = None
1006 try:
1007 tdir = tempfile.mkdtemp(suffix='gerrit_util')
1008 yield tdir
1009 finally:
1010 if tdir:
1011 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001012
1013
1014def ChangeIdentifier(project, change_number):
1015 """Returns change identifier "project~number" suitable for |chagne| arg of
1016 this module API.
1017
1018 Such format is allows for more efficient Gerrit routing of HTTP requests,
1019 comparing to specifying just change_number.
1020 """
1021 assert int(change_number)
1022 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001023
1024
1025# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001026_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001027assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1028
1029
1030def _UseGerritMirror(url, host):
1031 """Returns new url which uses randomly selected mirror for a gerrit host.
1032
1033 url's host should be for a given host or a result of prior call to this
1034 function.
1035
1036 Assumes url has a single occurence of the host substring.
1037 """
1038 assert host in url
1039 suffix = '-mirror-' + host
1040 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1041 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1042 st = url.find(suffix)
1043 if st == -1:
1044 actual_host = host
1045 else:
1046 # Already uses some mirror.
1047 assert st >= prefix_len, (uri, host, st, prefix_len)
1048 prefixes.remove(url[st-prefix_len:st])
1049 actual_host = url[st-prefix_len:st+len(suffix)]
1050 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)