blob: 565ebdbde66fbd552b3cdbb47cb73417a9f51e19 [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')
Edward Lemur47faa062018-10-11 19:46:02 +000050GERRIT_ERR_MESSAGE = (
51 'If you see this when running \'git cl upload\', please report this to '
52 'https://crbug.com/881860, and attach the failures in %s.\n' %
53 GERRIT_ERR_LOG_FILE)
Edward Lemur83bd7f42018-10-10 00:14:21 +000054INTERESTING_HEADERS = frozenset([
55 'x-google-backends',
56 'x-google-errorfiltertrace',
57 'x-google-filter-grace',
58 'x-errorid',
59])
60
61
szager@chromium.orgb4696232013-10-16 19:45:35 +000062class GerritError(Exception):
63 """Exception class for errors commuicating with the gerrit-on-borg service."""
64 def __init__(self, http_status, *args, **kwargs):
65 super(GerritError, self).__init__(*args, **kwargs)
66 self.http_status = http_status
67 self.message = '(%d) %s' % (self.http_status, self.message)
68
69
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000070class GerritAuthenticationError(GerritError):
71 """Exception class for authentication errors during Gerrit communication."""
72
73
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020074def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000075 """Encodes query parameters in the key:val[+key:val...] format specified here:
76
77 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
78 """
79 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020080 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000081 return '+'.join(q)
82
83
Aaron Gabled2db5a22017-03-24 14:14:15 -070084def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000085 if protocol is None:
86 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010087 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070088 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000089 else:
90 raise RuntimeError(
91 "Don't know how to work with protocol '%s'" % protocol)
92
93
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000094class Authenticator(object):
95 """Base authenticator class for authenticator implementations to subclass."""
96
97 def get_auth_header(self, host):
98 raise NotImplementedError()
99
100 @staticmethod
101 def get():
102 """Returns: (Authenticator) The identified Authenticator to use.
103
104 Probes the local system and its environment and identifies the
105 Authenticator instance to use.
106 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700107 # LUCI Context takes priority since it's normally present only on bots,
108 # which then must use it.
109 if LuciContextAuthenticator.is_luci():
110 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000111 if GceAuthenticator.is_gce():
112 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000113 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000114
115
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000116class CookiesAuthenticator(Authenticator):
117 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
118
119 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000120 """
121
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000122 _EMPTY = object()
123
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000124 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000125 # Credentials will be loaded lazily on first use. This ensures Authenticator
126 # get() can always construct an authenticator, even if something is broken.
127 # This allows 'creds-check' to proceed to actually checking creds later,
128 # rigorously (instead of blowing up with a cryptic error if they are wrong).
129 self._netrc = self._EMPTY
130 self._gitcookies = self._EMPTY
131
132 @property
133 def netrc(self):
134 if self._netrc is self._EMPTY:
135 self._netrc = self._get_netrc()
136 return self._netrc
137
138 @property
139 def gitcookies(self):
140 if self._gitcookies is self._EMPTY:
141 self._gitcookies = self._get_gitcookies()
142 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000143
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000144 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200145 def get_new_password_url(cls, host):
146 assert not host.startswith('http')
147 # Assume *.googlesource.com pattern.
148 parts = host.split('.')
149 if not parts[0].endswith('-review'):
150 parts[0] += '-review'
151 return 'https://%s/new-password' % ('.'.join(parts))
152
153 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000154 def get_new_password_message(cls, host):
155 assert not host.startswith('http')
156 # Assume *.googlesource.com pattern.
157 parts = host.split('.')
158 if not parts[0].endswith('-review'):
159 parts[0] += '-review'
160 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100161 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000162
163 @classmethod
164 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000165 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000166 return os.path.expanduser(os.path.join('~', path))
167
168 @classmethod
169 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800170 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000171 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000172 if not os.path.exists(path):
173 return netrc.netrc(os.devnull)
174
175 st = os.stat(path)
176 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
177 print >> sys.stderr, (
178 'WARNING: netrc file %s cannot be used because its file '
179 'permissions are insecure. netrc file permissions should be '
180 '600.' % path)
181 with open(path) as fd:
182 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800183
184 # Load the '.netrc' file. We strip comments from it because processing them
185 # can trigger a bug in Windows. See crbug.com/664664.
186 content = '\n'.join(l for l in content.splitlines()
187 if l.strip() and not l.strip().startswith('#'))
188 with tempdir() as tdir:
189 netrc_path = os.path.join(tdir, 'netrc')
190 with open(netrc_path, 'w') as fd:
191 fd.write(content)
192 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
193 return cls._get_netrc_from_path(netrc_path)
194
195 @classmethod
196 def _get_netrc_from_path(cls, path):
197 try:
198 return netrc.netrc(path)
199 except IOError:
200 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
201 return netrc.netrc(os.devnull)
202 except netrc.NetrcParseError as e:
203 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
204 'parsing error: %s' % (path, e))
205 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000206
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000207 @classmethod
208 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500209 if os.getenv('GIT_COOKIES_PATH'):
210 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800211 try:
212 return subprocess2.check_output(
213 ['git', 'config', '--path', 'http.cookiefile']).strip()
214 except subprocess2.CalledProcessError:
215 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000216
217 @classmethod
218 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000219 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000220 path = cls.get_gitcookies_path()
221 if not os.path.exists(path):
222 return gitcookies
223
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000224 try:
225 f = open(path, 'rb')
226 except IOError:
227 return gitcookies
228
229 with f:
230 for line in f:
231 try:
232 fields = line.strip().split('\t')
233 if line.strip().startswith('#') or len(fields) != 7:
234 continue
235 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
236 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000237 if value.startswith('git-'):
238 login, secret_token = value.split('=', 1)
239 gitcookies[domain] = (login, secret_token)
240 else:
241 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000242 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100243 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000244 return gitcookies
245
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100246 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000247 for domain, creds in self.gitcookies.iteritems():
248 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100249 return (creds[0], None, creds[1])
250 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000251
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100252 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700253 a = self._get_auth_for_host(host)
254 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000255 if a[0]:
256 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
257 else:
258 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000259 return None
260
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100261 def get_auth_email(self, host):
262 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700263 a = self._get_auth_for_host(host)
264 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100265 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700266 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100267 # login typically looks like 'git-xxx.example.com'
268 if not login.startswith('git-') or '.' not in login:
269 return None
270 username, domain = login[len('git-'):].split('.', 1)
271 return '%s@%s' % (username, domain)
272
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100273
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000274# Backwards compatibility just in case somebody imports this outside of
275# depot_tools.
276NetrcAuthenticator = CookiesAuthenticator
277
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000278
279class GceAuthenticator(Authenticator):
280 """Authenticator implementation that uses GCE metadata service for token.
281 """
282
283 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700284 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
285 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000286 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
287
288 _cache_is_gce = None
289 _token_cache = None
290 _token_expiration = None
291
292 @classmethod
293 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500294 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
295 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000296 if cls._cache_is_gce is None:
297 cls._cache_is_gce = cls._test_is_gce()
298 return cls._cache_is_gce
299
300 @classmethod
301 def _test_is_gce(cls):
302 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
303 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100304 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100305 except (socket.error, httplib2.ServerNotFoundError,
306 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000307 # Could not resolve URL.
308 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100309 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000310
311 @staticmethod
312 def _get(url, **kwargs):
313 next_delay_sec = 1
314 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000315 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700316 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100317 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000318 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
319 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100320 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000321
Aaron Gable92e9f382017-12-07 11:47:41 -0800322 # Retry server error status codes.
323 LOGGER.warn('Encountered server error')
324 if TRY_LIMIT - i > 1:
325 LOGGER.info('Will retry in %d seconds (%d more times)...',
326 next_delay_sec, TRY_LIMIT - i - 1)
327 time.sleep(next_delay_sec)
328 next_delay_sec *= 2
329
330
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000331 @classmethod
332 def _get_token_dict(cls):
333 if cls._token_cache:
334 # If it expires within 25 seconds, refresh.
335 if cls._token_expiration < time.time() - 25:
336 return cls._token_cache
337
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100338 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000339 if resp.status != httplib.OK:
340 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100341 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000342 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
343 return cls._token_cache
344
345 def get_auth_header(self, _host):
346 token_dict = self._get_token_dict()
347 if not token_dict:
348 return None
349 return '%(token_type)s %(access_token)s' % token_dict
350
351
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700352class LuciContextAuthenticator(Authenticator):
353 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
354 """
355
356 @staticmethod
357 def is_luci():
358 return auth.has_luci_context_local_auth()
359
360 def __init__(self):
361 self._access_token = None
362 self._ensure_fresh()
363
364 def _ensure_fresh(self):
365 if not self._access_token or self._access_token.needs_refresh():
366 self._access_token = auth.get_luci_context_access_token(
367 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
368
369 def get_auth_header(self, _host):
370 self._ensure_fresh()
371 return 'Bearer %s' % self._access_token.token
372
373
szager@chromium.orgb4696232013-10-16 19:45:35 +0000374def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
375 """Opens an https connection to a gerrit service, and sends a request."""
376 headers = headers or {}
377 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000378
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700379 a = Authenticator.get().get_auth_header(bare_host)
380 if a:
381 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000382 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000383 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000384
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800385 url = path
386 if not url.startswith('/'):
387 url = '/' + url
388 if 'Authorization' in headers and not url.startswith('/a/'):
389 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000390
szager@chromium.orgb4696232013-10-16 19:45:35 +0000391 if body:
392 body = json.JSONEncoder().encode(body)
393 headers.setdefault('Content-Type', 'application/json')
394 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000395 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000396 for key, val in headers.iteritems():
397 if key == 'Authorization':
398 val = 'HIDDEN'
399 LOGGER.debug('%s: %s' % (key, val))
400 if body:
401 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700402 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000403 # HACK: httplib.Http has no such attribute; we store req_host here for later
404 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000405 conn.req_host = host
406 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100407 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000408 'method': reqtype,
409 'headers': headers,
410 'body': body,
411 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000412 return conn
413
414
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700415def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000416 """Reads an http response from a connection into a string buffer.
417
418 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100419 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700420 accept_statuses: Treat any of these statuses as success. Default: [200]
421 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000422 Returns: A string buffer containing the connection's reply.
423 """
Steve Kobes56117722018-09-13 18:18:35 +0000424 sleep_time = 1.5
Edward Lemur83bd7f42018-10-10 00:14:21 +0000425 failed = False
szager@chromium.orgb4696232013-10-16 19:45:35 +0000426 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100427 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000428
429 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100430 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000431 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
432 www_authenticate):
433 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
434 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700435 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000436 'has credentials for %s' % host)
437 raise GerritAuthenticationError(response.status, reason)
438
szager@chromium.orgb4696232013-10-16 19:45:35 +0000439 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000440 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700441 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000442 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700443 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100444 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100445 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700446 # If 404 was in accept_statuses, then it's expected that the file might
447 # not exist, so don't return the gitiles error page because that's not the
448 # "content" that was actually requested.
449 if response.status == 404:
450 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000451 break
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000452 if response.status == 404:
453 # TODO(crbug/881860): remove this hack.
454 # HACK: try different Gerrit mirror as a workaround for potentially
455 # out-of-date mirror hit through default routing.
456 if conn.req_host == 'chromium-review.googlesource.com':
457 conn.req_params['uri'] = _UseGerritMirror(
458 conn.req_params['uri'], 'chromium-review.googlesource.com')
459 # And don't increase sleep_time in this case, since we suspect we've
460 # just asked wrong git mirror before.
461 sleep_time /= 2.0
Edward Lemur83bd7f42018-10-10 00:14:21 +0000462 failed = True
463 rpc_headers = '\n'.join(
464 ' ' + header + ': ' + value
465 for header, value in response.iteritems()
466 if header.lower() in INTERESTING_HEADERS
467 )
468 GERRIT_ERR_LOGGER.info('Gerrit RPC failures:\n%s\n', rpc_headers)
469 else:
470 # A status >=500 is assumed to be a possible transient error; retry.
471 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
472 LOGGER.warn('A transient error occurred while querying %s:\n'
473 '%s %s %s\n'
474 '%s %d %s',
475 conn.req_host, conn.req_params['method'],
476 conn.req_params['uri'],
477 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000478
szager@chromium.orgb4696232013-10-16 19:45:35 +0000479 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800480 LOGGER.info('Will retry in %d seconds (%d more times)...',
481 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000482 time.sleep(sleep_time)
483 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000484 # end of retries loop
485
486 if failed:
Edward Lemur47faa062018-10-11 19:46:02 +0000487 LOGGER.warn(GERRIT_ERR_MESSAGE)
Aaron Gable19ee16c2017-04-18 11:56:35 -0700488 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200489 if response.status in (401, 403):
490 print('Your Gerrit credentials might be misconfigured. Try: \n'
491 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100492 reason = '%s: %s' % (response.reason, contents)
Edward Lemur47faa062018-10-11 19:46:02 +0000493 if failed:
494 reason += '\n' + GERRIT_ERR_MESSAGE
nodir@chromium.orga7798032014-04-30 23:40:53 +0000495 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100496 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000497
498
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700499def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000500 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700501 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000502 # The first line of the response should always be: )]}'
503 s = fh.readline()
504 if s and s.rstrip() != ")]}'":
505 raise GerritError(200, 'Unexpected json output: %s' % s)
506 s = fh.read()
507 if not s:
508 return None
509 return json.loads(s)
510
511
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200512def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100513 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000514 """
515 Queries a gerrit-on-borg server for changes matching query terms.
516
517 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200518 params: A list of key:value pairs for search parameters, as documented
519 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
520 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000521 first_param: A change identifier
522 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100523 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000524 o_params: A list of additional output specifiers, as documented here:
525 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
526 Returns:
527 A list of json-decoded query results.
528 """
529 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200530 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000531 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200532 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100533 if start:
534 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000535 if limit:
536 path = '%s&n=%d' % (path, limit)
537 if o_params:
538 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700539 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000540
541
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200542def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100543 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000544 """
545 Queries a gerrit-on-borg server for all the changes matching the query terms.
546
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100547 WARNING: this is unreliable if a change matching the query is modified while
548 this function is being called.
549
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000550 A single query to gerrit-on-borg is limited on the number of results by the
551 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100552 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000553
554 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200555 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000556 limit: Maximum number of requested changes per query.
557 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100558 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000559
560 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100561 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000562 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100563 already_returned = set()
564 def at_most_once(cls):
565 for cl in cls:
566 if cl['_number'] not in already_returned:
567 already_returned.add(cl['_number'])
568 yield cl
569
570 start = start or 0
571 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000572 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100573
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000574 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100575 # This will fetch changes[start..start+limit] sorted by most recently
576 # updated. Since the rank of any change in this list can be changed any time
577 # (say user posting comment), subsequent calls may overalp like this:
578 # > initial order ABCDEFGH
579 # query[0..3] => ABC
580 # > E get's updated. New order: EABCDFGH
581 # query[3..6] => CDF # C is a dup
582 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200583 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100584 cur_start)
585 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000586 yield cl
587
588 more_changes = [cl for cl in page if '_more_changes' in cl]
589 if len(more_changes) > 1:
590 raise GerritError(
591 200,
592 'Received %d changes with a _more_changes attribute set but should '
593 'receive at most one.' % len(more_changes))
594 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100595 cur_start += len(page)
596
597 # If we paged through, query again the first page which in most circumstances
598 # will fetch all changes that were modified while this function was run.
599 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200600 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100601 for cl in at_most_once(page):
602 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000603
604
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200605def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100606 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000607 """Initiate a query composed of multiple sets of query parameters."""
608 if not change_list:
609 raise RuntimeError(
610 "MultiQueryChanges requires a list of change numbers/id's")
611 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200612 if params:
613 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000614 if limit:
615 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100616 if start:
617 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000618 if o_params:
619 q.extend(['o=%s' % p for p in o_params])
620 path = 'changes/?%s' % '&'.join(q)
621 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700622 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000623 except GerritError as e:
624 msg = '%s:\n%s' % (e.message, path)
625 raise GerritError(e.http_status, msg)
626 return result
627
628
629def GetGerritFetchUrl(host):
630 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
631 return '%s://%s/' % (GERRIT_PROTOCOL, host)
632
633
634def GetChangePageUrl(host, change_number):
635 """Given a gerrit host name and change number, return change page url."""
636 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
637
638
639def GetChangeUrl(host, change):
640 """Given a gerrit host name and change id, return an url for the change."""
641 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
642
643
644def GetChange(host, change):
645 """Query a gerrit server for information about a single change."""
646 path = 'changes/%s' % change
647 return ReadHttpJsonResponse(CreateHttpConn(host, path))
648
649
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700650def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000651 """Query a gerrit server for extended information about a single change."""
652 path = 'changes/%s/detail' % change
653 if o_params:
654 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700655 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000656
657
agable32978d92016-11-01 12:55:02 -0700658def GetChangeCommit(host, change, revision='current'):
659 """Query a gerrit server for a revision associated with a change."""
660 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
661 return ReadHttpJsonResponse(CreateHttpConn(host, path))
662
663
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664def GetChangeCurrentRevision(host, change):
665 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200666 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000667
668
669def GetChangeRevisions(host, change):
670 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200671 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000672
673
674def GetChangeReview(host, change, revision=None):
675 """Get the current review information for a change."""
676 if not revision:
677 jmsg = GetChangeRevisions(host, change)
678 if not jmsg:
679 return None
680 elif len(jmsg) > 1:
681 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
682 revision = jmsg[0]['current_revision']
683 path = 'changes/%s/revisions/%s/review'
684 return ReadHttpJsonResponse(CreateHttpConn(host, path))
685
686
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700687def GetChangeComments(host, change):
688 """Get the line- and file-level comments on a change."""
689 path = 'changes/%s/comments' % change
690 return ReadHttpJsonResponse(CreateHttpConn(host, path))
691
692
szager@chromium.orgb4696232013-10-16 19:45:35 +0000693def AbandonChange(host, change, msg=''):
694 """Abandon a gerrit change."""
695 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000696 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000697 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700698 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000699
700
701def RestoreChange(host, change, msg=''):
702 """Restore a previously abandoned change."""
703 path = 'changes/%s/restore' % 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 SubmitChange(host, change, wait_for_merge=True):
710 """Submits a gerrit change via Gerrit."""
711 path = 'changes/%s/submit' % change
712 body = {'wait_for_merge': wait_for_merge}
713 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
dsansomee2d6fd92016-09-08 00:10:47 -0700717def HasPendingChangeEdit(host, change):
718 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
719 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700720 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700721 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700722 # 204 No Content means no pending change.
723 if e.http_status == 204:
724 return False
725 raise
726 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700727
728
729def DeletePendingChangeEdit(host, change):
730 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700731 # On success, gerrit returns status 204; if the edit was already deleted it
732 # returns 404. Anything else is an error.
733 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700734
735
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100736def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000737 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700738 assert notify in ('ALL', 'NONE')
739 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700740 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700741 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000742 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700743 ReadHttpResponse(conn, accept_statuses=[200, 204])
744 except GerritError as e:
745 raise GerritError(
746 e.http_status,
747 'Received unexpected http status while editing message '
748 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000749
750
szager@chromium.orgb4696232013-10-16 19:45:35 +0000751def GetReviewers(host, change):
752 """Get information about all reviewers attached to a change."""
753 path = 'changes/%s/reviewers' % change
754 return ReadHttpJsonResponse(CreateHttpConn(host, path))
755
756
757def GetReview(host, change, revision):
758 """Get review information about a specific revision of a change."""
759 path = 'changes/%s/revisions/%s/review' % (change, revision)
760 return ReadHttpJsonResponse(CreateHttpConn(host, path))
761
762
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700763def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
764 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000765 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700766 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800767 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200768 if not change:
769 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700770 reviewers = frozenset(reviewers or [])
771 ccs = frozenset(ccs or [])
772 path = 'changes/%s/revisions/current/review' % change
773
774 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800775 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700776 'reviewers': [],
777 'notify': 'ALL' if notify else 'NONE',
778 }
779 for r in sorted(reviewers | ccs):
780 state = 'REVIEWER' if r in reviewers else 'CC'
781 body['reviewers'].append({
782 'reviewer': r,
783 'state': state,
784 'notify': 'NONE', # We handled `notify` argument above.
785 })
786
787 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
788 # Gerrit will return 400 if one or more of the requested reviewers are
789 # unprocessable. We read the response object to see which were rejected,
790 # warn about them, and retry with the remainder.
791 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
792
793 errored = set()
794 for result in resp.get('reviewers', {}).itervalues():
795 r = result.get('input')
796 state = 'REVIEWER' if r in reviewers else 'CC'
797 if result.get('error'):
798 errored.add(r)
799 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
800 if errored:
801 # Try again, adding only those that didn't fail, and only accepting 200.
802 AddReviewers(host, change, reviewers=(reviewers-errored),
803 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000804
805
806def RemoveReviewers(host, change, remove=None):
807 """Remove reveiewers from a change."""
808 if not remove:
809 return
810 if isinstance(remove, basestring):
811 remove = (remove,)
812 for r in remove:
813 path = 'changes/%s/reviewers/%s' % (change, r)
814 conn = CreateHttpConn(host, path, reqtype='DELETE')
815 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700816 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000817 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000818 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700819 e.http_status,
820 'Received unexpected http status while deleting reviewer "%s" '
821 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000822
823
Aaron Gable636b13f2017-07-14 10:42:48 -0700824def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000825 """Set labels and/or add a message to a code review."""
826 if not msg and not labels:
827 return
828 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800829 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000830 if msg:
831 body['message'] = msg
832 if labels:
833 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700834 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700835 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700836 if ready:
837 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000838 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
839 response = ReadHttpJsonResponse(conn)
840 if labels:
841 for key, val in labels.iteritems():
842 if ('labels' not in response or key not in response['labels'] or
843 int(response['labels'][key] != int(val))):
844 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
845 key, change))
846
847
848def ResetReviewLabels(host, change, label, value='0', message=None,
849 notify=None):
850 """Reset the value of a given label for all reviewers on a change."""
851 # This is tricky, because we want to work on the "current revision", but
852 # there's always the risk that "current revision" will change in between
853 # API calls. So, we check "current revision" at the beginning and end; if
854 # it has changed, raise an exception.
855 jmsg = GetChangeCurrentRevision(host, change)
856 if not jmsg:
857 raise GerritError(
858 200, 'Could not get review information for change "%s"' % change)
859 value = str(value)
860 revision = jmsg[0]['current_revision']
861 path = 'changes/%s/revisions/%s/review' % (change, revision)
862 message = message or (
863 '%s label set to %s programmatically.' % (label, value))
864 jmsg = GetReview(host, change, revision)
865 if not jmsg:
866 raise GerritError(200, 'Could not get review information for revison %s '
867 'of change %s' % (revision, change))
868 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
869 if str(review.get('value', value)) != value:
870 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800871 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000872 'message': message,
873 'labels': {label: value},
874 'on_behalf_of': review['_account_id'],
875 }
876 if notify:
877 body['notify'] = notify
878 conn = CreateHttpConn(
879 host, path, reqtype='POST', body=body)
880 response = ReadHttpJsonResponse(conn)
881 if str(response['labels'][label]) != value:
882 username = review.get('email', jmsg.get('name', ''))
883 raise GerritError(200, 'Unable to set %s label for user "%s"'
884 ' on change %s.' % (label, username, change))
885 jmsg = GetChangeCurrentRevision(host, change)
886 if not jmsg:
887 raise GerritError(
888 200, 'Could not get review information for change "%s"' % change)
889 elif jmsg[0]['current_revision'] != revision:
890 raise GerritError(200, 'While resetting labels on change "%s", '
891 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800892
893
dimu833c94c2017-01-18 17:36:15 -0800894def CreateGerritBranch(host, project, branch, commit):
895 """
896 Create a new branch from given project and commit
897 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
898
899 Returns:
900 A JSON with 'ref' key
901 """
902 path = 'projects/%s/branches/%s' % (project, branch)
903 body = {'revision': commit}
904 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700905 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800906 if response:
907 return response
908 raise GerritError(200, 'Unable to create gerrit branch')
909
910
911def GetGerritBranch(host, project, branch):
912 """
913 Get a branch from given project and commit
914 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
915
916 Returns:
917 A JSON object with 'revision' key
918 """
919 path = 'projects/%s/branches/%s' % (project, branch)
920 conn = CreateHttpConn(host, path, reqtype='GET')
921 response = ReadHttpJsonResponse(conn)
922 if response:
923 return response
924 raise GerritError(200, 'Unable to get gerrit branch')
925
926
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100927def GetAccountDetails(host, account_id='self'):
928 """Returns details of the account.
929
930 If account_id is not given, uses magic value 'self' which corresponds to
931 whichever account user is authenticating as.
932
933 Documentation:
934 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
935 """
936 if account_id != 'self':
937 account_id = int(account_id)
938 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
939 return ReadHttpJsonResponse(conn)
940
941
Nick Carter8692b182017-11-06 16:30:38 -0800942def PercentEncodeForGitRef(original):
943 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
944
945 The encoding used is based on but stricter than URL encoding (Section 2.1
946 of RFC 3986). The only non-escaped characters are alphanumerics, and
947 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
948 'PLUS SIGN' (U+002B).
949
950 For more information, see the Gerrit docs here:
951
952 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
953 """
954 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
955 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
956
957 # spaces are not allowed in git refs; gerrit will interpret either '_' or
958 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
959 return encoded.replace(' ', '_')
960
961
Dan Jacques8d11e482016-11-15 14:25:56 -0800962@contextlib.contextmanager
963def tempdir():
964 tdir = None
965 try:
966 tdir = tempfile.mkdtemp(suffix='gerrit_util')
967 yield tdir
968 finally:
969 if tdir:
970 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000971
972
973def ChangeIdentifier(project, change_number):
974 """Returns change identifier "project~number" suitable for |chagne| arg of
975 this module API.
976
977 Such format is allows for more efficient Gerrit routing of HTTP requests,
978 comparing to specifying just change_number.
979 """
980 assert int(change_number)
981 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000982
983
984# TODO(crbug/881860): remove this hack.
985_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3']
986assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
987
988
989def _UseGerritMirror(url, host):
990 """Returns new url which uses randomly selected mirror for a gerrit host.
991
992 url's host should be for a given host or a result of prior call to this
993 function.
994
995 Assumes url has a single occurence of the host substring.
996 """
997 assert host in url
998 suffix = '-mirror-' + host
999 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1000 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1001 st = url.find(suffix)
1002 if st == -1:
1003 actual_host = host
1004 else:
1005 # Already uses some mirror.
1006 assert st >= prefix_len, (uri, host, st, prefix_len)
1007 prefixes.remove(url[st-prefix_len:st])
1008 actual_host = url[st-prefix_len:st+len(suffix)]
1009 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)