blob: 3003421eddb3aa5cbc664506fc117a3a1dcf2f9d [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
Aaron Gable8797cab2018-03-06 13:55:00 -080033import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010034from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000035
szager@chromium.orgb4696232013-10-16 19:45:35 +000036LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000037# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
38# total tries, the sleep time between the first and last tries will be 94.5 sec.
39# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
40TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000041
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000042
szager@chromium.orgb4696232013-10-16 19:45:35 +000043# Controls the transport protocol used to communicate with gerrit.
44# This is parameterized primarily to enable GerritTestCase.
45GERRIT_PROTOCOL = 'https'
46
47
Edward Lemur83bd7f42018-10-10 00:14:21 +000048# TODO(crbug.com/881860): Remove.
49GERRIT_ERR_LOGGER = logging.getLogger('GerritErrorLogs')
50GERRIT_ERR_LOG_FILE = os.path.join(tempfile.gettempdir(), 'GerritHeaders.txt')
Edward Lemur47faa062018-10-11 19:46:02 +000051GERRIT_ERR_MESSAGE = (
52 'If you see this when running \'git cl upload\', please report this to '
53 'https://crbug.com/881860, and attach the failures in %s.\n' %
54 GERRIT_ERR_LOG_FILE)
Edward Lemur83bd7f42018-10-10 00:14:21 +000055INTERESTING_HEADERS = frozenset([
56 'x-google-backends',
57 'x-google-errorfiltertrace',
58 'x-google-filter-grace',
59 'x-errorid',
60])
61
62
szager@chromium.orgb4696232013-10-16 19:45:35 +000063class GerritError(Exception):
64 """Exception class for errors commuicating with the gerrit-on-borg service."""
65 def __init__(self, http_status, *args, **kwargs):
66 super(GerritError, self).__init__(*args, **kwargs)
67 self.http_status = http_status
68 self.message = '(%d) %s' % (self.http_status, self.message)
69
70
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000071class GerritAuthenticationError(GerritError):
72 """Exception class for authentication errors during Gerrit communication."""
73
74
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020075def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000076 """Encodes query parameters in the key:val[+key:val...] format specified here:
77
78 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
79 """
80 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020081 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000082 return '+'.join(q)
83
84
Aaron Gabled2db5a22017-03-24 14:14:15 -070085def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000086 if protocol is None:
87 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010088 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070089 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000090 else:
91 raise RuntimeError(
92 "Don't know how to work with protocol '%s'" % protocol)
93
94
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000095class Authenticator(object):
96 """Base authenticator class for authenticator implementations to subclass."""
97
98 def get_auth_header(self, host):
99 raise NotImplementedError()
100
101 @staticmethod
102 def get():
103 """Returns: (Authenticator) The identified Authenticator to use.
104
105 Probes the local system and its environment and identifies the
106 Authenticator instance to use.
107 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700108 # LUCI Context takes priority since it's normally present only on bots,
109 # which then must use it.
110 if LuciContextAuthenticator.is_luci():
111 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000112 if GceAuthenticator.is_gce():
113 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000114 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000115
116
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000117class CookiesAuthenticator(Authenticator):
118 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
119
120 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000121 """
122
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000123 _EMPTY = object()
124
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000125 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000126 # Credentials will be loaded lazily on first use. This ensures Authenticator
127 # get() can always construct an authenticator, even if something is broken.
128 # This allows 'creds-check' to proceed to actually checking creds later,
129 # rigorously (instead of blowing up with a cryptic error if they are wrong).
130 self._netrc = self._EMPTY
131 self._gitcookies = self._EMPTY
132
133 @property
134 def netrc(self):
135 if self._netrc is self._EMPTY:
136 self._netrc = self._get_netrc()
137 return self._netrc
138
139 @property
140 def gitcookies(self):
141 if self._gitcookies is self._EMPTY:
142 self._gitcookies = self._get_gitcookies()
143 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000144
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000145 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200146 def get_new_password_url(cls, host):
147 assert not host.startswith('http')
148 # Assume *.googlesource.com pattern.
149 parts = host.split('.')
150 if not parts[0].endswith('-review'):
151 parts[0] += '-review'
152 return 'https://%s/new-password' % ('.'.join(parts))
153
154 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000155 def get_new_password_message(cls, host):
156 assert not host.startswith('http')
157 # Assume *.googlesource.com pattern.
158 parts = host.split('.')
159 if not parts[0].endswith('-review'):
160 parts[0] += '-review'
161 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100162 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000163
164 @classmethod
165 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000166 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000167 return os.path.expanduser(os.path.join('~', path))
168
169 @classmethod
170 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800171 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000172 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000173 if not os.path.exists(path):
174 return netrc.netrc(os.devnull)
175
176 st = os.stat(path)
177 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
178 print >> sys.stderr, (
179 'WARNING: netrc file %s cannot be used because its file '
180 'permissions are insecure. netrc file permissions should be '
181 '600.' % path)
182 with open(path) as fd:
183 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800184
185 # Load the '.netrc' file. We strip comments from it because processing them
186 # can trigger a bug in Windows. See crbug.com/664664.
187 content = '\n'.join(l for l in content.splitlines()
188 if l.strip() and not l.strip().startswith('#'))
189 with tempdir() as tdir:
190 netrc_path = os.path.join(tdir, 'netrc')
191 with open(netrc_path, 'w') as fd:
192 fd.write(content)
193 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
194 return cls._get_netrc_from_path(netrc_path)
195
196 @classmethod
197 def _get_netrc_from_path(cls, path):
198 try:
199 return netrc.netrc(path)
200 except IOError:
201 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
202 return netrc.netrc(os.devnull)
203 except netrc.NetrcParseError as e:
204 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
205 'parsing error: %s' % (path, e))
206 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000207
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000208 @classmethod
209 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500210 if os.getenv('GIT_COOKIES_PATH'):
211 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800212 try:
213 return subprocess2.check_output(
214 ['git', 'config', '--path', 'http.cookiefile']).strip()
215 except subprocess2.CalledProcessError:
216 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000217
218 @classmethod
219 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000220 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000221 path = cls.get_gitcookies_path()
222 if not os.path.exists(path):
223 return gitcookies
224
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000225 try:
226 f = open(path, 'rb')
227 except IOError:
228 return gitcookies
229
230 with f:
231 for line in f:
232 try:
233 fields = line.strip().split('\t')
234 if line.strip().startswith('#') or len(fields) != 7:
235 continue
236 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
237 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000238 if value.startswith('git-'):
239 login, secret_token = value.split('=', 1)
240 gitcookies[domain] = (login, secret_token)
241 else:
242 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000243 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100244 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000245 return gitcookies
246
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100247 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000248 for domain, creds in self.gitcookies.iteritems():
249 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100250 return (creds[0], None, creds[1])
251 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000252
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100253 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700254 a = self._get_auth_for_host(host)
255 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000256 if a[0]:
257 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
258 else:
259 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000260 return None
261
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100262 def get_auth_email(self, host):
263 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700264 a = self._get_auth_for_host(host)
265 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100266 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700267 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100268 # login typically looks like 'git-xxx.example.com'
269 if not login.startswith('git-') or '.' not in login:
270 return None
271 username, domain = login[len('git-'):].split('.', 1)
272 return '%s@%s' % (username, domain)
273
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100274
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000275# Backwards compatibility just in case somebody imports this outside of
276# depot_tools.
277NetrcAuthenticator = CookiesAuthenticator
278
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000279
280class GceAuthenticator(Authenticator):
281 """Authenticator implementation that uses GCE metadata service for token.
282 """
283
284 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700285 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
286 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000287 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
288
289 _cache_is_gce = None
290 _token_cache = None
291 _token_expiration = None
292
293 @classmethod
294 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500295 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
296 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000297 if cls._cache_is_gce is None:
298 cls._cache_is_gce = cls._test_is_gce()
299 return cls._cache_is_gce
300
301 @classmethod
302 def _test_is_gce(cls):
303 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
304 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100305 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100306 except (socket.error, httplib2.ServerNotFoundError,
307 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000308 # Could not resolve URL.
309 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100310 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000311
312 @staticmethod
313 def _get(url, **kwargs):
314 next_delay_sec = 1
315 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000316 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700317 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100318 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000319 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
320 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100321 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000322
Aaron Gable92e9f382017-12-07 11:47:41 -0800323 # Retry server error status codes.
324 LOGGER.warn('Encountered server error')
325 if TRY_LIMIT - i > 1:
326 LOGGER.info('Will retry in %d seconds (%d more times)...',
327 next_delay_sec, TRY_LIMIT - i - 1)
328 time.sleep(next_delay_sec)
329 next_delay_sec *= 2
330
331
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000332 @classmethod
333 def _get_token_dict(cls):
334 if cls._token_cache:
335 # If it expires within 25 seconds, refresh.
336 if cls._token_expiration < time.time() - 25:
337 return cls._token_cache
338
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100339 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000340 if resp.status != httplib.OK:
341 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100342 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000343 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
344 return cls._token_cache
345
346 def get_auth_header(self, _host):
347 token_dict = self._get_token_dict()
348 if not token_dict:
349 return None
350 return '%(token_type)s %(access_token)s' % token_dict
351
352
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700353class LuciContextAuthenticator(Authenticator):
354 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
355 """
356
357 @staticmethod
358 def is_luci():
359 return auth.has_luci_context_local_auth()
360
361 def __init__(self):
362 self._access_token = None
363 self._ensure_fresh()
364
365 def _ensure_fresh(self):
366 if not self._access_token or self._access_token.needs_refresh():
367 self._access_token = auth.get_luci_context_access_token(
368 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
369
370 def get_auth_header(self, _host):
371 self._ensure_fresh()
372 return 'Bearer %s' % self._access_token.token
373
374
szager@chromium.orgb4696232013-10-16 19:45:35 +0000375def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
376 """Opens an https connection to a gerrit service, and sends a request."""
377 headers = headers or {}
378 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000379
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700380 a = Authenticator.get().get_auth_header(bare_host)
381 if a:
382 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000383 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000384 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000385
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800386 url = path
387 if not url.startswith('/'):
388 url = '/' + url
389 if 'Authorization' in headers and not url.startswith('/a/'):
390 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000391
szager@chromium.orgb4696232013-10-16 19:45:35 +0000392 if body:
393 body = json.JSONEncoder().encode(body)
394 headers.setdefault('Content-Type', 'application/json')
395 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000396 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000397 for key, val in headers.iteritems():
398 if key == 'Authorization':
399 val = 'HIDDEN'
400 LOGGER.debug('%s: %s' % (key, val))
401 if body:
402 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700403 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000404 # HACK: httplib.Http has no such attribute; we store req_host here for later
405 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000406 conn.req_host = host
407 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100408 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000409 'method': reqtype,
410 'headers': headers,
411 'body': body,
412 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000413 return conn
414
415
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700416def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000417 """Reads an http response from a connection into a string buffer.
418
419 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100420 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700421 accept_statuses: Treat any of these statuses as success. Default: [200]
422 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000423 Returns: A string buffer containing the connection's reply.
424 """
Steve Kobes56117722018-09-13 18:18:35 +0000425 sleep_time = 1.5
Edward Lemur83bd7f42018-10-10 00:14:21 +0000426 failed = False
szager@chromium.orgb4696232013-10-16 19:45:35 +0000427 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100428 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000429
430 # 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
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000453 if response.status == 404:
454 # TODO(crbug/881860): remove this hack.
455 # HACK: try different Gerrit mirror as a workaround for potentially
456 # out-of-date mirror hit through default routing.
457 if conn.req_host == 'chromium-review.googlesource.com':
458 conn.req_params['uri'] = _UseGerritMirror(
459 conn.req_params['uri'], 'chromium-review.googlesource.com')
460 # And don't increase sleep_time in this case, since we suspect we've
461 # just asked wrong git mirror before.
462 sleep_time /= 2.0
Edward Lemur83bd7f42018-10-10 00:14:21 +0000463 failed = True
464 rpc_headers = '\n'.join(
465 ' ' + header + ': ' + value
466 for header, value in response.iteritems()
467 if header.lower() in INTERESTING_HEADERS
468 )
Edward Lemurb0b43f32018-10-16 22:49:27 +0000469 GERRIT_ERR_LOGGER.info(
470 'Gerrit RPC failure headers:\n'
471 ' Host: %s\n'
472 ' Ip: %s\n'
473 '%s\n',
474 conn.connections.values()[0].host,
475 conn.connections.values()[0].sock.getpeername(),
476 rpc_headers)
Edward Lemur83bd7f42018-10-10 00:14:21 +0000477 else:
478 # A status >=500 is assumed to be a possible transient error; retry.
479 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
480 LOGGER.warn('A transient error occurred while querying %s:\n'
481 '%s %s %s\n'
482 '%s %d %s',
483 conn.req_host, conn.req_params['method'],
484 conn.req_params['uri'],
485 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000486
szager@chromium.orgb4696232013-10-16 19:45:35 +0000487 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800488 LOGGER.info('Will retry in %d seconds (%d more times)...',
489 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000490 time.sleep(sleep_time)
491 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000492 # end of retries loop
493
494 if failed:
Edward Lemur47faa062018-10-11 19:46:02 +0000495 LOGGER.warn(GERRIT_ERR_MESSAGE)
Aaron Gable19ee16c2017-04-18 11:56:35 -0700496 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200497 if response.status in (401, 403):
498 print('Your Gerrit credentials might be misconfigured. Try: \n'
499 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100500 reason = '%s: %s' % (response.reason, contents)
Edward Lemur47faa062018-10-11 19:46:02 +0000501 if failed:
502 reason += '\n' + GERRIT_ERR_MESSAGE
nodir@chromium.orga7798032014-04-30 23:40:53 +0000503 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100504 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000505
506
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700507def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000508 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700509 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000510 # The first line of the response should always be: )]}'
511 s = fh.readline()
512 if s and s.rstrip() != ")]}'":
513 raise GerritError(200, 'Unexpected json output: %s' % s)
514 s = fh.read()
515 if not s:
516 return None
517 return json.loads(s)
518
519
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200520def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100521 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000522 """
523 Queries a gerrit-on-borg server for changes matching query terms.
524
525 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200526 params: A list of key:value pairs for search parameters, as documented
527 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
528 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000529 first_param: A change identifier
530 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000532 o_params: A list of additional output specifiers, as documented here:
533 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
534 Returns:
535 A list of json-decoded query results.
536 """
537 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200538 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000539 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200540 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100541 if start:
542 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000543 if limit:
544 path = '%s&n=%d' % (path, limit)
545 if o_params:
546 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700547 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000548
549
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200550def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100551 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000552 """
553 Queries a gerrit-on-borg server for all the changes matching the query terms.
554
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100555 WARNING: this is unreliable if a change matching the query is modified while
556 this function is being called.
557
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000558 A single query to gerrit-on-borg is limited on the number of results by the
559 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100560 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000561
562 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200563 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000564 limit: Maximum number of requested changes per query.
565 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100566 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000567
568 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100569 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000570 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100571 already_returned = set()
572 def at_most_once(cls):
573 for cl in cls:
574 if cl['_number'] not in already_returned:
575 already_returned.add(cl['_number'])
576 yield cl
577
578 start = start or 0
579 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000580 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100581
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000582 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100583 # This will fetch changes[start..start+limit] sorted by most recently
584 # updated. Since the rank of any change in this list can be changed any time
585 # (say user posting comment), subsequent calls may overalp like this:
586 # > initial order ABCDEFGH
587 # query[0..3] => ABC
588 # > E get's updated. New order: EABCDFGH
589 # query[3..6] => CDF # C is a dup
590 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200591 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100592 cur_start)
593 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000594 yield cl
595
596 more_changes = [cl for cl in page if '_more_changes' in cl]
597 if len(more_changes) > 1:
598 raise GerritError(
599 200,
600 'Received %d changes with a _more_changes attribute set but should '
601 'receive at most one.' % len(more_changes))
602 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100603 cur_start += len(page)
604
605 # If we paged through, query again the first page which in most circumstances
606 # will fetch all changes that were modified while this function was run.
607 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200608 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100609 for cl in at_most_once(page):
610 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000611
612
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200613def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100614 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000615 """Initiate a query composed of multiple sets of query parameters."""
616 if not change_list:
617 raise RuntimeError(
618 "MultiQueryChanges requires a list of change numbers/id's")
619 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200620 if params:
621 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000622 if limit:
623 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100624 if start:
625 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000626 if o_params:
627 q.extend(['o=%s' % p for p in o_params])
628 path = 'changes/?%s' % '&'.join(q)
629 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700630 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000631 except GerritError as e:
632 msg = '%s:\n%s' % (e.message, path)
633 raise GerritError(e.http_status, msg)
634 return result
635
636
637def GetGerritFetchUrl(host):
638 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
639 return '%s://%s/' % (GERRIT_PROTOCOL, host)
640
641
642def GetChangePageUrl(host, change_number):
643 """Given a gerrit host name and change number, return change page url."""
644 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
645
646
647def GetChangeUrl(host, change):
648 """Given a gerrit host name and change id, return an url for the change."""
649 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
650
651
652def GetChange(host, change):
653 """Query a gerrit server for information about a single change."""
654 path = 'changes/%s' % change
655 return ReadHttpJsonResponse(CreateHttpConn(host, path))
656
657
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700658def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000659 """Query a gerrit server for extended information about a single change."""
660 path = 'changes/%s/detail' % change
661 if o_params:
662 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700663 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664
665
agable32978d92016-11-01 12:55:02 -0700666def GetChangeCommit(host, change, revision='current'):
667 """Query a gerrit server for a revision associated with a change."""
668 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
669 return ReadHttpJsonResponse(CreateHttpConn(host, path))
670
671
szager@chromium.orgb4696232013-10-16 19:45:35 +0000672def GetChangeCurrentRevision(host, change):
673 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200674 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000675
676
677def GetChangeRevisions(host, change):
678 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200679 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000680
681
682def GetChangeReview(host, change, revision=None):
683 """Get the current review information for a change."""
684 if not revision:
685 jmsg = GetChangeRevisions(host, change)
686 if not jmsg:
687 return None
688 elif len(jmsg) > 1:
689 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
690 revision = jmsg[0]['current_revision']
691 path = 'changes/%s/revisions/%s/review'
692 return ReadHttpJsonResponse(CreateHttpConn(host, path))
693
694
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700695def GetChangeComments(host, change):
696 """Get the line- and file-level comments on a change."""
697 path = 'changes/%s/comments' % change
698 return ReadHttpJsonResponse(CreateHttpConn(host, path))
699
700
szager@chromium.orgb4696232013-10-16 19:45:35 +0000701def AbandonChange(host, change, msg=''):
702 """Abandon a gerrit change."""
703 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000704 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000705 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700706 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000707
708
709def RestoreChange(host, change, msg=''):
710 """Restore a previously abandoned change."""
711 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000712 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000713 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700714 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000715
716
717def SubmitChange(host, change, wait_for_merge=True):
718 """Submits a gerrit change via Gerrit."""
719 path = 'changes/%s/submit' % change
720 body = {'wait_for_merge': wait_for_merge}
721 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700722 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000723
724
dsansomee2d6fd92016-09-08 00:10:47 -0700725def HasPendingChangeEdit(host, change):
726 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
727 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700728 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700729 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700730 # 204 No Content means no pending change.
731 if e.http_status == 204:
732 return False
733 raise
734 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700735
736
737def DeletePendingChangeEdit(host, change):
738 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700739 # On success, gerrit returns status 204; if the edit was already deleted it
740 # returns 404. Anything else is an error.
741 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700742
743
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100744def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000745 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700746 assert notify in ('ALL', 'NONE')
747 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700748 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700749 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000750 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700751 ReadHttpResponse(conn, accept_statuses=[200, 204])
752 except GerritError as e:
753 raise GerritError(
754 e.http_status,
755 'Received unexpected http status while editing message '
756 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000757
758
szager@chromium.orgb4696232013-10-16 19:45:35 +0000759def GetReviewers(host, change):
760 """Get information about all reviewers attached to a change."""
761 path = 'changes/%s/reviewers' % change
762 return ReadHttpJsonResponse(CreateHttpConn(host, path))
763
764
765def GetReview(host, change, revision):
766 """Get review information about a specific revision of a change."""
767 path = 'changes/%s/revisions/%s/review' % (change, revision)
768 return ReadHttpJsonResponse(CreateHttpConn(host, path))
769
770
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700771def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
772 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000773 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700774 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800775 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200776 if not change:
777 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700778 reviewers = frozenset(reviewers or [])
779 ccs = frozenset(ccs or [])
780 path = 'changes/%s/revisions/current/review' % change
781
782 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800783 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700784 'reviewers': [],
785 'notify': 'ALL' if notify else 'NONE',
786 }
787 for r in sorted(reviewers | ccs):
788 state = 'REVIEWER' if r in reviewers else 'CC'
789 body['reviewers'].append({
790 'reviewer': r,
791 'state': state,
792 'notify': 'NONE', # We handled `notify` argument above.
793 })
794
795 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
796 # Gerrit will return 400 if one or more of the requested reviewers are
797 # unprocessable. We read the response object to see which were rejected,
798 # warn about them, and retry with the remainder.
799 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
800
801 errored = set()
802 for result in resp.get('reviewers', {}).itervalues():
803 r = result.get('input')
804 state = 'REVIEWER' if r in reviewers else 'CC'
805 if result.get('error'):
806 errored.add(r)
807 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
808 if errored:
809 # Try again, adding only those that didn't fail, and only accepting 200.
810 AddReviewers(host, change, reviewers=(reviewers-errored),
811 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000812
813
814def RemoveReviewers(host, change, remove=None):
815 """Remove reveiewers from a change."""
816 if not remove:
817 return
818 if isinstance(remove, basestring):
819 remove = (remove,)
820 for r in remove:
821 path = 'changes/%s/reviewers/%s' % (change, r)
822 conn = CreateHttpConn(host, path, reqtype='DELETE')
823 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700824 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000825 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000826 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700827 e.http_status,
828 'Received unexpected http status while deleting reviewer "%s" '
829 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000830
831
Aaron Gable636b13f2017-07-14 10:42:48 -0700832def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000833 """Set labels and/or add a message to a code review."""
834 if not msg and not labels:
835 return
836 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800837 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000838 if msg:
839 body['message'] = msg
840 if labels:
841 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700842 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700843 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700844 if ready:
845 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000846 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
847 response = ReadHttpJsonResponse(conn)
848 if labels:
849 for key, val in labels.iteritems():
850 if ('labels' not in response or key not in response['labels'] or
851 int(response['labels'][key] != int(val))):
852 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
853 key, change))
854
855
856def ResetReviewLabels(host, change, label, value='0', message=None,
857 notify=None):
858 """Reset the value of a given label for all reviewers on a change."""
859 # This is tricky, because we want to work on the "current revision", but
860 # there's always the risk that "current revision" will change in between
861 # API calls. So, we check "current revision" at the beginning and end; if
862 # it has changed, raise an exception.
863 jmsg = GetChangeCurrentRevision(host, change)
864 if not jmsg:
865 raise GerritError(
866 200, 'Could not get review information for change "%s"' % change)
867 value = str(value)
868 revision = jmsg[0]['current_revision']
869 path = 'changes/%s/revisions/%s/review' % (change, revision)
870 message = message or (
871 '%s label set to %s programmatically.' % (label, value))
872 jmsg = GetReview(host, change, revision)
873 if not jmsg:
874 raise GerritError(200, 'Could not get review information for revison %s '
875 'of change %s' % (revision, change))
876 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
877 if str(review.get('value', value)) != value:
878 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800879 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000880 'message': message,
881 'labels': {label: value},
882 'on_behalf_of': review['_account_id'],
883 }
884 if notify:
885 body['notify'] = notify
886 conn = CreateHttpConn(
887 host, path, reqtype='POST', body=body)
888 response = ReadHttpJsonResponse(conn)
889 if str(response['labels'][label]) != value:
890 username = review.get('email', jmsg.get('name', ''))
891 raise GerritError(200, 'Unable to set %s label for user "%s"'
892 ' on change %s.' % (label, username, change))
893 jmsg = GetChangeCurrentRevision(host, change)
894 if not jmsg:
895 raise GerritError(
896 200, 'Could not get review information for change "%s"' % change)
897 elif jmsg[0]['current_revision'] != revision:
898 raise GerritError(200, 'While resetting labels on change "%s", '
899 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800900
901
dimu833c94c2017-01-18 17:36:15 -0800902def CreateGerritBranch(host, project, branch, commit):
903 """
904 Create a new branch from given project and commit
905 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
906
907 Returns:
908 A JSON with 'ref' key
909 """
910 path = 'projects/%s/branches/%s' % (project, branch)
911 body = {'revision': commit}
912 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700913 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800914 if response:
915 return response
916 raise GerritError(200, 'Unable to create gerrit branch')
917
918
919def GetGerritBranch(host, project, branch):
920 """
921 Get a branch from given project and commit
922 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
923
924 Returns:
925 A JSON object with 'revision' key
926 """
927 path = 'projects/%s/branches/%s' % (project, branch)
928 conn = CreateHttpConn(host, path, reqtype='GET')
929 response = ReadHttpJsonResponse(conn)
930 if response:
931 return response
932 raise GerritError(200, 'Unable to get gerrit branch')
933
934
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100935def GetAccountDetails(host, account_id='self'):
936 """Returns details of the account.
937
938 If account_id is not given, uses magic value 'self' which corresponds to
939 whichever account user is authenticating as.
940
941 Documentation:
942 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000943
944 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100945 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100946 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000947 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
948
949
950def ValidAccounts(host, accounts, max_threads=10):
951 """Returns a mapping from valid account to its details.
952
953 Invalid accounts, either not existing or without unique match,
954 are not present as returned dictionary keys.
955 """
956 assert not isinstance(accounts, basestring), type(accounts)
957 accounts = list(set(accounts))
958 if not accounts:
959 return {}
960 def get_one(account):
961 try:
962 return account, GetAccountDetails(host, account)
963 except GerritError:
964 return None, None
965 valid = {}
966 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
967 for account, details in pool.map(get_one, accounts):
968 if account and details:
969 valid[account] = details
970 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100971
972
Nick Carter8692b182017-11-06 16:30:38 -0800973def PercentEncodeForGitRef(original):
974 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
975
976 The encoding used is based on but stricter than URL encoding (Section 2.1
977 of RFC 3986). The only non-escaped characters are alphanumerics, and
978 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
979 'PLUS SIGN' (U+002B).
980
981 For more information, see the Gerrit docs here:
982
983 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
984 """
985 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
986 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
987
988 # spaces are not allowed in git refs; gerrit will interpret either '_' or
989 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
990 return encoded.replace(' ', '_')
991
992
Dan Jacques8d11e482016-11-15 14:25:56 -0800993@contextlib.contextmanager
994def tempdir():
995 tdir = None
996 try:
997 tdir = tempfile.mkdtemp(suffix='gerrit_util')
998 yield tdir
999 finally:
1000 if tdir:
1001 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001002
1003
1004def ChangeIdentifier(project, change_number):
1005 """Returns change identifier "project~number" suitable for |chagne| arg of
1006 this module API.
1007
1008 Such format is allows for more efficient Gerrit routing of HTTP requests,
1009 comparing to specifying just change_number.
1010 """
1011 assert int(change_number)
1012 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001013
1014
1015# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001016_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001017assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1018
1019
1020def _UseGerritMirror(url, host):
1021 """Returns new url which uses randomly selected mirror for a gerrit host.
1022
1023 url's host should be for a given host or a result of prior call to this
1024 function.
1025
1026 Assumes url has a single occurence of the host substring.
1027 """
1028 assert host in url
1029 suffix = '-mirror-' + host
1030 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1031 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1032 st = url.find(suffix)
1033 if st == -1:
1034 actual_host = host
1035 else:
1036 # Already uses some mirror.
1037 assert st >= prefix_len, (uri, host, st, prefix_len)
1038 prefixes.remove(url[st-prefix_len:st])
1039 actual_host = url[st-prefix_len:st+len(suffix)]
1040 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)