blob: f69dcbcab16667870e6e3ec5e6bca926dc63ea2d [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
29
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070030import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080031import gclient_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080032import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010033from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000034
szager@chromium.orgb4696232013-10-16 19:45:35 +000035LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000036# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
37# total tries, the sleep time between the first and last tries will be 94.5 sec.
38# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
39TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000040
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000041
szager@chromium.orgb4696232013-10-16 19:45:35 +000042# Controls the transport protocol used to communicate with gerrit.
43# This is parameterized primarily to enable GerritTestCase.
44GERRIT_PROTOCOL = 'https'
45
46
Edward Lemur83bd7f42018-10-10 00:14:21 +000047# TODO(crbug.com/881860): Remove.
48GERRIT_ERR_LOGGER = logging.getLogger('GerritErrorLogs')
49GERRIT_ERR_LOG_FILE = os.path.join(tempfile.gettempdir(), 'GerritHeaders.txt')
50INTERESTING_HEADERS = frozenset([
51 'x-google-backends',
52 'x-google-errorfiltertrace',
53 'x-google-filter-grace',
54 'x-errorid',
55])
56
57
szager@chromium.orgb4696232013-10-16 19:45:35 +000058class GerritError(Exception):
59 """Exception class for errors commuicating with the gerrit-on-borg service."""
60 def __init__(self, http_status, *args, **kwargs):
61 super(GerritError, self).__init__(*args, **kwargs)
62 self.http_status = http_status
63 self.message = '(%d) %s' % (self.http_status, self.message)
64
65
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000066class GerritAuthenticationError(GerritError):
67 """Exception class for authentication errors during Gerrit communication."""
68
69
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020070def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000071 """Encodes query parameters in the key:val[+key:val...] format specified here:
72
73 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
74 """
75 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020076 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000077 return '+'.join(q)
78
79
Aaron Gabled2db5a22017-03-24 14:14:15 -070080def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000081 if protocol is None:
82 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010083 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070084 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000085 else:
86 raise RuntimeError(
87 "Don't know how to work with protocol '%s'" % protocol)
88
89
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000090class Authenticator(object):
91 """Base authenticator class for authenticator implementations to subclass."""
92
93 def get_auth_header(self, host):
94 raise NotImplementedError()
95
96 @staticmethod
97 def get():
98 """Returns: (Authenticator) The identified Authenticator to use.
99
100 Probes the local system and its environment and identifies the
101 Authenticator instance to use.
102 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700103 # LUCI Context takes priority since it's normally present only on bots,
104 # which then must use it.
105 if LuciContextAuthenticator.is_luci():
106 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000107 if GceAuthenticator.is_gce():
108 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000109 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000110
111
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000112class CookiesAuthenticator(Authenticator):
113 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
114
115 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000116 """
117
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000118 _EMPTY = object()
119
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000120 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000121 # Credentials will be loaded lazily on first use. This ensures Authenticator
122 # get() can always construct an authenticator, even if something is broken.
123 # This allows 'creds-check' to proceed to actually checking creds later,
124 # rigorously (instead of blowing up with a cryptic error if they are wrong).
125 self._netrc = self._EMPTY
126 self._gitcookies = self._EMPTY
127
128 @property
129 def netrc(self):
130 if self._netrc is self._EMPTY:
131 self._netrc = self._get_netrc()
132 return self._netrc
133
134 @property
135 def gitcookies(self):
136 if self._gitcookies is self._EMPTY:
137 self._gitcookies = self._get_gitcookies()
138 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000139
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000140 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200141 def get_new_password_url(cls, host):
142 assert not host.startswith('http')
143 # Assume *.googlesource.com pattern.
144 parts = host.split('.')
145 if not parts[0].endswith('-review'):
146 parts[0] += '-review'
147 return 'https://%s/new-password' % ('.'.join(parts))
148
149 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000150 def get_new_password_message(cls, host):
151 assert not host.startswith('http')
152 # Assume *.googlesource.com pattern.
153 parts = host.split('.')
154 if not parts[0].endswith('-review'):
155 parts[0] += '-review'
156 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100157 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000158
159 @classmethod
160 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000161 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000162 return os.path.expanduser(os.path.join('~', path))
163
164 @classmethod
165 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800166 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000167 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000168 if not os.path.exists(path):
169 return netrc.netrc(os.devnull)
170
171 st = os.stat(path)
172 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
173 print >> sys.stderr, (
174 'WARNING: netrc file %s cannot be used because its file '
175 'permissions are insecure. netrc file permissions should be '
176 '600.' % path)
177 with open(path) as fd:
178 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800179
180 # Load the '.netrc' file. We strip comments from it because processing them
181 # can trigger a bug in Windows. See crbug.com/664664.
182 content = '\n'.join(l for l in content.splitlines()
183 if l.strip() and not l.strip().startswith('#'))
184 with tempdir() as tdir:
185 netrc_path = os.path.join(tdir, 'netrc')
186 with open(netrc_path, 'w') as fd:
187 fd.write(content)
188 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
189 return cls._get_netrc_from_path(netrc_path)
190
191 @classmethod
192 def _get_netrc_from_path(cls, path):
193 try:
194 return netrc.netrc(path)
195 except IOError:
196 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
197 return netrc.netrc(os.devnull)
198 except netrc.NetrcParseError as e:
199 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
200 'parsing error: %s' % (path, e))
201 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000202
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000203 @classmethod
204 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500205 if os.getenv('GIT_COOKIES_PATH'):
206 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800207 try:
208 return subprocess2.check_output(
209 ['git', 'config', '--path', 'http.cookiefile']).strip()
210 except subprocess2.CalledProcessError:
211 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000212
213 @classmethod
214 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000215 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000216 path = cls.get_gitcookies_path()
217 if not os.path.exists(path):
218 return gitcookies
219
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000220 try:
221 f = open(path, 'rb')
222 except IOError:
223 return gitcookies
224
225 with f:
226 for line in f:
227 try:
228 fields = line.strip().split('\t')
229 if line.strip().startswith('#') or len(fields) != 7:
230 continue
231 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
232 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000233 if value.startswith('git-'):
234 login, secret_token = value.split('=', 1)
235 gitcookies[domain] = (login, secret_token)
236 else:
237 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000238 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100239 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000240 return gitcookies
241
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100242 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000243 for domain, creds in self.gitcookies.iteritems():
244 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100245 return (creds[0], None, creds[1])
246 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000247
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100248 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700249 a = self._get_auth_for_host(host)
250 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000251 if a[0]:
252 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
253 else:
254 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000255 return None
256
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100257 def get_auth_email(self, host):
258 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700259 a = self._get_auth_for_host(host)
260 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100261 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700262 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100263 # login typically looks like 'git-xxx.example.com'
264 if not login.startswith('git-') or '.' not in login:
265 return None
266 username, domain = login[len('git-'):].split('.', 1)
267 return '%s@%s' % (username, domain)
268
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100269
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000270# Backwards compatibility just in case somebody imports this outside of
271# depot_tools.
272NetrcAuthenticator = CookiesAuthenticator
273
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000274
275class GceAuthenticator(Authenticator):
276 """Authenticator implementation that uses GCE metadata service for token.
277 """
278
279 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700280 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
281 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000282 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
283
284 _cache_is_gce = None
285 _token_cache = None
286 _token_expiration = None
287
288 @classmethod
289 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500290 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
291 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000292 if cls._cache_is_gce is None:
293 cls._cache_is_gce = cls._test_is_gce()
294 return cls._cache_is_gce
295
296 @classmethod
297 def _test_is_gce(cls):
298 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
299 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100300 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100301 except (socket.error, httplib2.ServerNotFoundError,
302 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 # Could not resolve URL.
304 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100305 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000306
307 @staticmethod
308 def _get(url, **kwargs):
309 next_delay_sec = 1
310 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000311 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700312 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100313 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000314 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
315 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100316 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000317
Aaron Gable92e9f382017-12-07 11:47:41 -0800318 # Retry server error status codes.
319 LOGGER.warn('Encountered server error')
320 if TRY_LIMIT - i > 1:
321 LOGGER.info('Will retry in %d seconds (%d more times)...',
322 next_delay_sec, TRY_LIMIT - i - 1)
323 time.sleep(next_delay_sec)
324 next_delay_sec *= 2
325
326
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000327 @classmethod
328 def _get_token_dict(cls):
329 if cls._token_cache:
330 # If it expires within 25 seconds, refresh.
331 if cls._token_expiration < time.time() - 25:
332 return cls._token_cache
333
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100334 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000335 if resp.status != httplib.OK:
336 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100337 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000338 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
339 return cls._token_cache
340
341 def get_auth_header(self, _host):
342 token_dict = self._get_token_dict()
343 if not token_dict:
344 return None
345 return '%(token_type)s %(access_token)s' % token_dict
346
347
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700348class LuciContextAuthenticator(Authenticator):
349 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
350 """
351
352 @staticmethod
353 def is_luci():
354 return auth.has_luci_context_local_auth()
355
356 def __init__(self):
357 self._access_token = None
358 self._ensure_fresh()
359
360 def _ensure_fresh(self):
361 if not self._access_token or self._access_token.needs_refresh():
362 self._access_token = auth.get_luci_context_access_token(
363 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
364
365 def get_auth_header(self, _host):
366 self._ensure_fresh()
367 return 'Bearer %s' % self._access_token.token
368
369
szager@chromium.orgb4696232013-10-16 19:45:35 +0000370def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
371 """Opens an https connection to a gerrit service, and sends a request."""
372 headers = headers or {}
373 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000374
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700375 a = Authenticator.get().get_auth_header(bare_host)
376 if a:
377 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000378 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000379 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000380
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800381 url = path
382 if not url.startswith('/'):
383 url = '/' + url
384 if 'Authorization' in headers and not url.startswith('/a/'):
385 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000386
szager@chromium.orgb4696232013-10-16 19:45:35 +0000387 if body:
388 body = json.JSONEncoder().encode(body)
389 headers.setdefault('Content-Type', 'application/json')
390 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000391 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000392 for key, val in headers.iteritems():
393 if key == 'Authorization':
394 val = 'HIDDEN'
395 LOGGER.debug('%s: %s' % (key, val))
396 if body:
397 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700398 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000399 # HACK: httplib.Http has no such attribute; we store req_host here for later
400 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000401 conn.req_host = host
402 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100403 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000404 'method': reqtype,
405 'headers': headers,
406 'body': body,
407 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000408 return conn
409
410
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700411def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000412 """Reads an http response from a connection into a string buffer.
413
414 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100415 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700416 accept_statuses: Treat any of these statuses as success. Default: [200]
417 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000418 Returns: A string buffer containing the connection's reply.
419 """
Steve Kobes56117722018-09-13 18:18:35 +0000420 sleep_time = 1.5
Edward Lemur83bd7f42018-10-10 00:14:21 +0000421 failed = False
szager@chromium.orgb4696232013-10-16 19:45:35 +0000422 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100423 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000424
425 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100426 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000427 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
428 www_authenticate):
429 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
430 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700431 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000432 'has credentials for %s' % host)
433 raise GerritAuthenticationError(response.status, reason)
434
szager@chromium.orgb4696232013-10-16 19:45:35 +0000435 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000436 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700437 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000438 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700439 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100440 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100441 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700442 # If 404 was in accept_statuses, then it's expected that the file might
443 # not exist, so don't return the gitiles error page because that's not the
444 # "content" that was actually requested.
445 if response.status == 404:
446 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000447 break
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000448 if response.status == 404:
449 # TODO(crbug/881860): remove this hack.
450 # HACK: try different Gerrit mirror as a workaround for potentially
451 # out-of-date mirror hit through default routing.
452 if conn.req_host == 'chromium-review.googlesource.com':
453 conn.req_params['uri'] = _UseGerritMirror(
454 conn.req_params['uri'], 'chromium-review.googlesource.com')
455 # And don't increase sleep_time in this case, since we suspect we've
456 # just asked wrong git mirror before.
457 sleep_time /= 2.0
Edward Lemur83bd7f42018-10-10 00:14:21 +0000458 failed = True
459 rpc_headers = '\n'.join(
460 ' ' + header + ': ' + value
461 for header, value in response.iteritems()
462 if header.lower() in INTERESTING_HEADERS
463 )
464 GERRIT_ERR_LOGGER.info('Gerrit RPC failures:\n%s\n', rpc_headers)
465 else:
466 # A status >=500 is assumed to be a possible transient error; retry.
467 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
468 LOGGER.warn('A transient error occurred while querying %s:\n'
469 '%s %s %s\n'
470 '%s %d %s',
471 conn.req_host, conn.req_params['method'],
472 conn.req_params['uri'],
473 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000474
szager@chromium.orgb4696232013-10-16 19:45:35 +0000475 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800476 LOGGER.info('Will retry in %d seconds (%d more times)...',
477 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000478 time.sleep(sleep_time)
479 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000480 # end of retries loop
481
482 if failed:
483 LOGGER.warn(
484 'If you see this when running \'git cl upload\', consider '
485 'reporting this to https://crbug.com/881860, and please attach the '
486 'failures in %s.\n', GERRIT_ERR_LOG_FILE)
Aaron Gable19ee16c2017-04-18 11:56:35 -0700487 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200488 if response.status in (401, 403):
489 print('Your Gerrit credentials might be misconfigured. Try: \n'
490 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100491 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000492 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100493 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000494
495
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700496def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000497 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700498 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000499 # The first line of the response should always be: )]}'
500 s = fh.readline()
501 if s and s.rstrip() != ")]}'":
502 raise GerritError(200, 'Unexpected json output: %s' % s)
503 s = fh.read()
504 if not s:
505 return None
506 return json.loads(s)
507
508
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200509def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000511 """
512 Queries a gerrit-on-borg server for changes matching query terms.
513
514 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200515 params: A list of key:value pairs for search parameters, as documented
516 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
517 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000518 first_param: A change identifier
519 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100520 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000521 o_params: A list of additional output specifiers, as documented here:
522 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
523 Returns:
524 A list of json-decoded query results.
525 """
526 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200527 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000528 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200529 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100530 if start:
531 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000532 if limit:
533 path = '%s&n=%d' % (path, limit)
534 if o_params:
535 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700536 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000537
538
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200539def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100540 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000541 """
542 Queries a gerrit-on-borg server for all the changes matching the query terms.
543
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100544 WARNING: this is unreliable if a change matching the query is modified while
545 this function is being called.
546
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000547 A single query to gerrit-on-borg is limited on the number of results by the
548 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100549 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000550
551 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200552 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000553 limit: Maximum number of requested changes per query.
554 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100555 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000556
557 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100558 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000559 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100560 already_returned = set()
561 def at_most_once(cls):
562 for cl in cls:
563 if cl['_number'] not in already_returned:
564 already_returned.add(cl['_number'])
565 yield cl
566
567 start = start or 0
568 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000569 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100570
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000571 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100572 # This will fetch changes[start..start+limit] sorted by most recently
573 # updated. Since the rank of any change in this list can be changed any time
574 # (say user posting comment), subsequent calls may overalp like this:
575 # > initial order ABCDEFGH
576 # query[0..3] => ABC
577 # > E get's updated. New order: EABCDFGH
578 # query[3..6] => CDF # C is a dup
579 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200580 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100581 cur_start)
582 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000583 yield cl
584
585 more_changes = [cl for cl in page if '_more_changes' in cl]
586 if len(more_changes) > 1:
587 raise GerritError(
588 200,
589 'Received %d changes with a _more_changes attribute set but should '
590 'receive at most one.' % len(more_changes))
591 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100592 cur_start += len(page)
593
594 # If we paged through, query again the first page which in most circumstances
595 # will fetch all changes that were modified while this function was run.
596 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200597 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100598 for cl in at_most_once(page):
599 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000600
601
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200602def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100603 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000604 """Initiate a query composed of multiple sets of query parameters."""
605 if not change_list:
606 raise RuntimeError(
607 "MultiQueryChanges requires a list of change numbers/id's")
608 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200609 if params:
610 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000611 if limit:
612 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100613 if start:
614 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000615 if o_params:
616 q.extend(['o=%s' % p for p in o_params])
617 path = 'changes/?%s' % '&'.join(q)
618 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700619 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000620 except GerritError as e:
621 msg = '%s:\n%s' % (e.message, path)
622 raise GerritError(e.http_status, msg)
623 return result
624
625
626def GetGerritFetchUrl(host):
627 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
628 return '%s://%s/' % (GERRIT_PROTOCOL, host)
629
630
631def GetChangePageUrl(host, change_number):
632 """Given a gerrit host name and change number, return change page url."""
633 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
634
635
636def GetChangeUrl(host, change):
637 """Given a gerrit host name and change id, return an url for the change."""
638 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
639
640
641def GetChange(host, change):
642 """Query a gerrit server for information about a single change."""
643 path = 'changes/%s' % change
644 return ReadHttpJsonResponse(CreateHttpConn(host, path))
645
646
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700647def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000648 """Query a gerrit server for extended information about a single change."""
649 path = 'changes/%s/detail' % change
650 if o_params:
651 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700652 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000653
654
agable32978d92016-11-01 12:55:02 -0700655def GetChangeCommit(host, change, revision='current'):
656 """Query a gerrit server for a revision associated with a change."""
657 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
658 return ReadHttpJsonResponse(CreateHttpConn(host, path))
659
660
szager@chromium.orgb4696232013-10-16 19:45:35 +0000661def GetChangeCurrentRevision(host, change):
662 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200663 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664
665
666def GetChangeRevisions(host, change):
667 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200668 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000669
670
671def GetChangeReview(host, change, revision=None):
672 """Get the current review information for a change."""
673 if not revision:
674 jmsg = GetChangeRevisions(host, change)
675 if not jmsg:
676 return None
677 elif len(jmsg) > 1:
678 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
679 revision = jmsg[0]['current_revision']
680 path = 'changes/%s/revisions/%s/review'
681 return ReadHttpJsonResponse(CreateHttpConn(host, path))
682
683
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700684def GetChangeComments(host, change):
685 """Get the line- and file-level comments on a change."""
686 path = 'changes/%s/comments' % change
687 return ReadHttpJsonResponse(CreateHttpConn(host, path))
688
689
szager@chromium.orgb4696232013-10-16 19:45:35 +0000690def AbandonChange(host, change, msg=''):
691 """Abandon a gerrit change."""
692 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000693 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000694 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700695 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000696
697
698def RestoreChange(host, change, msg=''):
699 """Restore a previously abandoned change."""
700 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000701 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000702 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700703 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000704
705
706def SubmitChange(host, change, wait_for_merge=True):
707 """Submits a gerrit change via Gerrit."""
708 path = 'changes/%s/submit' % change
709 body = {'wait_for_merge': wait_for_merge}
710 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700711 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000712
713
dsansomee2d6fd92016-09-08 00:10:47 -0700714def HasPendingChangeEdit(host, change):
715 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
716 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700717 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700718 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700719 # 204 No Content means no pending change.
720 if e.http_status == 204:
721 return False
722 raise
723 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700724
725
726def DeletePendingChangeEdit(host, change):
727 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700728 # On success, gerrit returns status 204; if the edit was already deleted it
729 # returns 404. Anything else is an error.
730 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700731
732
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100733def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000734 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700735 assert notify in ('ALL', 'NONE')
736 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700737 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700738 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000739 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700740 ReadHttpResponse(conn, accept_statuses=[200, 204])
741 except GerritError as e:
742 raise GerritError(
743 e.http_status,
744 'Received unexpected http status while editing message '
745 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000746
747
szager@chromium.orgb4696232013-10-16 19:45:35 +0000748def GetReviewers(host, change):
749 """Get information about all reviewers attached to a change."""
750 path = 'changes/%s/reviewers' % change
751 return ReadHttpJsonResponse(CreateHttpConn(host, path))
752
753
754def GetReview(host, change, revision):
755 """Get review information about a specific revision of a change."""
756 path = 'changes/%s/revisions/%s/review' % (change, revision)
757 return ReadHttpJsonResponse(CreateHttpConn(host, path))
758
759
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700760def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
761 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000762 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700763 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800764 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200765 if not change:
766 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700767 reviewers = frozenset(reviewers or [])
768 ccs = frozenset(ccs or [])
769 path = 'changes/%s/revisions/current/review' % change
770
771 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800772 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700773 'reviewers': [],
774 'notify': 'ALL' if notify else 'NONE',
775 }
776 for r in sorted(reviewers | ccs):
777 state = 'REVIEWER' if r in reviewers else 'CC'
778 body['reviewers'].append({
779 'reviewer': r,
780 'state': state,
781 'notify': 'NONE', # We handled `notify` argument above.
782 })
783
784 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
785 # Gerrit will return 400 if one or more of the requested reviewers are
786 # unprocessable. We read the response object to see which were rejected,
787 # warn about them, and retry with the remainder.
788 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
789
790 errored = set()
791 for result in resp.get('reviewers', {}).itervalues():
792 r = result.get('input')
793 state = 'REVIEWER' if r in reviewers else 'CC'
794 if result.get('error'):
795 errored.add(r)
796 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
797 if errored:
798 # Try again, adding only those that didn't fail, and only accepting 200.
799 AddReviewers(host, change, reviewers=(reviewers-errored),
800 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000801
802
803def RemoveReviewers(host, change, remove=None):
804 """Remove reveiewers from a change."""
805 if not remove:
806 return
807 if isinstance(remove, basestring):
808 remove = (remove,)
809 for r in remove:
810 path = 'changes/%s/reviewers/%s' % (change, r)
811 conn = CreateHttpConn(host, path, reqtype='DELETE')
812 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700813 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000814 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000815 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700816 e.http_status,
817 'Received unexpected http status while deleting reviewer "%s" '
818 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000819
820
Aaron Gable636b13f2017-07-14 10:42:48 -0700821def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000822 """Set labels and/or add a message to a code review."""
823 if not msg and not labels:
824 return
825 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800826 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000827 if msg:
828 body['message'] = msg
829 if labels:
830 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700831 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700832 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700833 if ready:
834 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000835 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
836 response = ReadHttpJsonResponse(conn)
837 if labels:
838 for key, val in labels.iteritems():
839 if ('labels' not in response or key not in response['labels'] or
840 int(response['labels'][key] != int(val))):
841 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
842 key, change))
843
844
845def ResetReviewLabels(host, change, label, value='0', message=None,
846 notify=None):
847 """Reset the value of a given label for all reviewers on a change."""
848 # This is tricky, because we want to work on the "current revision", but
849 # there's always the risk that "current revision" will change in between
850 # API calls. So, we check "current revision" at the beginning and end; if
851 # it has changed, raise an exception.
852 jmsg = GetChangeCurrentRevision(host, change)
853 if not jmsg:
854 raise GerritError(
855 200, 'Could not get review information for change "%s"' % change)
856 value = str(value)
857 revision = jmsg[0]['current_revision']
858 path = 'changes/%s/revisions/%s/review' % (change, revision)
859 message = message or (
860 '%s label set to %s programmatically.' % (label, value))
861 jmsg = GetReview(host, change, revision)
862 if not jmsg:
863 raise GerritError(200, 'Could not get review information for revison %s '
864 'of change %s' % (revision, change))
865 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
866 if str(review.get('value', value)) != value:
867 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800868 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000869 'message': message,
870 'labels': {label: value},
871 'on_behalf_of': review['_account_id'],
872 }
873 if notify:
874 body['notify'] = notify
875 conn = CreateHttpConn(
876 host, path, reqtype='POST', body=body)
877 response = ReadHttpJsonResponse(conn)
878 if str(response['labels'][label]) != value:
879 username = review.get('email', jmsg.get('name', ''))
880 raise GerritError(200, 'Unable to set %s label for user "%s"'
881 ' on change %s.' % (label, username, change))
882 jmsg = GetChangeCurrentRevision(host, change)
883 if not jmsg:
884 raise GerritError(
885 200, 'Could not get review information for change "%s"' % change)
886 elif jmsg[0]['current_revision'] != revision:
887 raise GerritError(200, 'While resetting labels on change "%s", '
888 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800889
890
dimu833c94c2017-01-18 17:36:15 -0800891def CreateGerritBranch(host, project, branch, commit):
892 """
893 Create a new branch from given project and commit
894 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
895
896 Returns:
897 A JSON with 'ref' key
898 """
899 path = 'projects/%s/branches/%s' % (project, branch)
900 body = {'revision': commit}
901 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700902 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800903 if response:
904 return response
905 raise GerritError(200, 'Unable to create gerrit branch')
906
907
908def GetGerritBranch(host, project, branch):
909 """
910 Get a branch from given project and commit
911 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
912
913 Returns:
914 A JSON object with 'revision' key
915 """
916 path = 'projects/%s/branches/%s' % (project, branch)
917 conn = CreateHttpConn(host, path, reqtype='GET')
918 response = ReadHttpJsonResponse(conn)
919 if response:
920 return response
921 raise GerritError(200, 'Unable to get gerrit branch')
922
923
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100924def GetAccountDetails(host, account_id='self'):
925 """Returns details of the account.
926
927 If account_id is not given, uses magic value 'self' which corresponds to
928 whichever account user is authenticating as.
929
930 Documentation:
931 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
932 """
933 if account_id != 'self':
934 account_id = int(account_id)
935 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
936 return ReadHttpJsonResponse(conn)
937
938
Nick Carter8692b182017-11-06 16:30:38 -0800939def PercentEncodeForGitRef(original):
940 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
941
942 The encoding used is based on but stricter than URL encoding (Section 2.1
943 of RFC 3986). The only non-escaped characters are alphanumerics, and
944 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
945 'PLUS SIGN' (U+002B).
946
947 For more information, see the Gerrit docs here:
948
949 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
950 """
951 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
952 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
953
954 # spaces are not allowed in git refs; gerrit will interpret either '_' or
955 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
956 return encoded.replace(' ', '_')
957
958
Dan Jacques8d11e482016-11-15 14:25:56 -0800959@contextlib.contextmanager
960def tempdir():
961 tdir = None
962 try:
963 tdir = tempfile.mkdtemp(suffix='gerrit_util')
964 yield tdir
965 finally:
966 if tdir:
967 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000968
969
970def ChangeIdentifier(project, change_number):
971 """Returns change identifier "project~number" suitable for |chagne| arg of
972 this module API.
973
974 Such format is allows for more efficient Gerrit routing of HTTP requests,
975 comparing to specifying just change_number.
976 """
977 assert int(change_number)
978 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000979
980
981# TODO(crbug/881860): remove this hack.
982_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3']
983assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
984
985
986def _UseGerritMirror(url, host):
987 """Returns new url which uses randomly selected mirror for a gerrit host.
988
989 url's host should be for a given host or a result of prior call to this
990 function.
991
992 Assumes url has a single occurence of the host substring.
993 """
994 assert host in url
995 suffix = '-mirror-' + host
996 prefixes = set(_GERRIT_MIRROR_PREFIXES)
997 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
998 st = url.find(suffix)
999 if st == -1:
1000 actual_host = host
1001 else:
1002 # Already uses some mirror.
1003 assert st >= prefix_len, (uri, host, st, prefix_len)
1004 prefixes.remove(url[st-prefix_len:st])
1005 actual_host = url[st-prefix_len:st+len(suffix)]
1006 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)