blob: d97b73bcd6636d5d8eab17b937f48d37f81baec1 [file] [log] [blame]
szager@chromium.orgb4696232013-10-16 19:45:35 +00001# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""
6Utilities for requesting information for a gerrit server via https.
7
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
11import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080012import contextlib
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000013import cookielib
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010014import httplib # Still used for its constants.
szager@chromium.orgb4696232013-10-16 19:45:35 +000015import json
16import logging
17import netrc
18import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000019import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000020import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000021import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000022import stat
23import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080024import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000025import time
26import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000027import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000028from cStringIO import StringIO
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000029from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000030
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070031import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080032import gclient_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080033import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010034from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000035
szager@chromium.orgb4696232013-10-16 19:45:35 +000036LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000037# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
38# total tries, the sleep time between the first and last tries will be 94.5 sec.
39# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
40TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000041
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000042
szager@chromium.orgb4696232013-10-16 19:45:35 +000043# Controls the transport protocol used to communicate with gerrit.
44# This is parameterized primarily to enable GerritTestCase.
45GERRIT_PROTOCOL = 'https'
46
47
Edward Lemur83bd7f42018-10-10 00:14:21 +000048# TODO(crbug.com/881860): Remove.
49GERRIT_ERR_LOGGER = logging.getLogger('GerritErrorLogs')
50GERRIT_ERR_LOG_FILE = os.path.join(tempfile.gettempdir(), 'GerritHeaders.txt')
Edward Lemur47faa062018-10-11 19:46:02 +000051GERRIT_ERR_MESSAGE = (
52 'If you see this when running \'git cl upload\', please report this to '
53 'https://crbug.com/881860, and attach the failures in %s.\n' %
54 GERRIT_ERR_LOG_FILE)
Edward Lemur83bd7f42018-10-10 00:14:21 +000055INTERESTING_HEADERS = frozenset([
56 'x-google-backends',
57 'x-google-errorfiltertrace',
58 'x-google-filter-grace',
59 'x-errorid',
60])
61
62
szager@chromium.orgb4696232013-10-16 19:45:35 +000063class GerritError(Exception):
64 """Exception class for errors commuicating with the gerrit-on-borg service."""
65 def __init__(self, http_status, *args, **kwargs):
66 super(GerritError, self).__init__(*args, **kwargs)
67 self.http_status = http_status
68 self.message = '(%d) %s' % (self.http_status, self.message)
69
70
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000071class GerritAuthenticationError(GerritError):
72 """Exception class for authentication errors during Gerrit communication."""
73
74
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020075def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000076 """Encodes query parameters in the key:val[+key:val...] format specified here:
77
78 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
79 """
80 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020081 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000082 return '+'.join(q)
83
84
Aaron Gabled2db5a22017-03-24 14:14:15 -070085def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000086 if protocol is None:
87 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010088 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070089 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000090 else:
91 raise RuntimeError(
92 "Don't know how to work with protocol '%s'" % protocol)
93
94
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000095class Authenticator(object):
96 """Base authenticator class for authenticator implementations to subclass."""
97
98 def get_auth_header(self, host):
99 raise NotImplementedError()
100
101 @staticmethod
102 def get():
103 """Returns: (Authenticator) The identified Authenticator to use.
104
105 Probes the local system and its environment and identifies the
106 Authenticator instance to use.
107 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700108 # LUCI Context takes priority since it's normally present only on bots,
109 # which then must use it.
110 if LuciContextAuthenticator.is_luci():
111 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000112 if GceAuthenticator.is_gce():
113 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000114 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000115
116
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000117class CookiesAuthenticator(Authenticator):
118 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
119
120 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000121 """
122
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000123 _EMPTY = object()
124
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000125 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000126 # Credentials will be loaded lazily on first use. This ensures Authenticator
127 # get() can always construct an authenticator, even if something is broken.
128 # This allows 'creds-check' to proceed to actually checking creds later,
129 # rigorously (instead of blowing up with a cryptic error if they are wrong).
130 self._netrc = self._EMPTY
131 self._gitcookies = self._EMPTY
132
133 @property
134 def netrc(self):
135 if self._netrc is self._EMPTY:
136 self._netrc = self._get_netrc()
137 return self._netrc
138
139 @property
140 def gitcookies(self):
141 if self._gitcookies is self._EMPTY:
142 self._gitcookies = self._get_gitcookies()
143 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000144
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000145 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200146 def get_new_password_url(cls, host):
147 assert not host.startswith('http')
148 # Assume *.googlesource.com pattern.
149 parts = host.split('.')
150 if not parts[0].endswith('-review'):
151 parts[0] += '-review'
152 return 'https://%s/new-password' % ('.'.join(parts))
153
154 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000155 def get_new_password_message(cls, host):
156 assert not host.startswith('http')
157 # Assume *.googlesource.com pattern.
158 parts = host.split('.')
159 if not parts[0].endswith('-review'):
160 parts[0] += '-review'
161 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100162 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000163
164 @classmethod
165 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000166 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000167 return os.path.expanduser(os.path.join('~', path))
168
169 @classmethod
170 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800171 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000172 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000173 if not os.path.exists(path):
174 return netrc.netrc(os.devnull)
175
176 st = os.stat(path)
177 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
178 print >> sys.stderr, (
179 'WARNING: netrc file %s cannot be used because its file '
180 'permissions are insecure. netrc file permissions should be '
181 '600.' % path)
182 with open(path) as fd:
183 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800184
185 # Load the '.netrc' file. We strip comments from it because processing them
186 # can trigger a bug in Windows. See crbug.com/664664.
187 content = '\n'.join(l for l in content.splitlines()
188 if l.strip() and not l.strip().startswith('#'))
189 with tempdir() as tdir:
190 netrc_path = os.path.join(tdir, 'netrc')
191 with open(netrc_path, 'w') as fd:
192 fd.write(content)
193 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
194 return cls._get_netrc_from_path(netrc_path)
195
196 @classmethod
197 def _get_netrc_from_path(cls, path):
198 try:
199 return netrc.netrc(path)
200 except IOError:
201 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
202 return netrc.netrc(os.devnull)
203 except netrc.NetrcParseError as e:
204 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
205 'parsing error: %s' % (path, e))
206 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000207
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000208 @classmethod
209 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500210 if os.getenv('GIT_COOKIES_PATH'):
211 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800212 try:
213 return subprocess2.check_output(
214 ['git', 'config', '--path', 'http.cookiefile']).strip()
215 except subprocess2.CalledProcessError:
216 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000217
218 @classmethod
219 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000220 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000221 path = cls.get_gitcookies_path()
222 if not os.path.exists(path):
223 return gitcookies
224
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000225 try:
226 f = open(path, 'rb')
227 except IOError:
228 return gitcookies
229
230 with f:
231 for line in f:
232 try:
233 fields = line.strip().split('\t')
234 if line.strip().startswith('#') or len(fields) != 7:
235 continue
236 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
237 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000238 if value.startswith('git-'):
239 login, secret_token = value.split('=', 1)
240 gitcookies[domain] = (login, secret_token)
241 else:
242 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000243 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100244 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000245 return gitcookies
246
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100247 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000248 for domain, creds in self.gitcookies.iteritems():
249 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100250 return (creds[0], None, creds[1])
251 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000252
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100253 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700254 a = self._get_auth_for_host(host)
255 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000256 if a[0]:
257 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
258 else:
259 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000260 return None
261
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100262 def get_auth_email(self, host):
263 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700264 a = self._get_auth_for_host(host)
265 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100266 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700267 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100268 # login typically looks like 'git-xxx.example.com'
269 if not login.startswith('git-') or '.' not in login:
270 return None
271 username, domain = login[len('git-'):].split('.', 1)
272 return '%s@%s' % (username, domain)
273
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100274
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000275# Backwards compatibility just in case somebody imports this outside of
276# depot_tools.
277NetrcAuthenticator = CookiesAuthenticator
278
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000279
280class GceAuthenticator(Authenticator):
281 """Authenticator implementation that uses GCE metadata service for token.
282 """
283
284 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700285 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
286 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000287 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
288
289 _cache_is_gce = None
290 _token_cache = None
291 _token_expiration = None
292
293 @classmethod
294 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500295 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
296 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000297 if cls._cache_is_gce is None:
298 cls._cache_is_gce = cls._test_is_gce()
299 return cls._cache_is_gce
300
301 @classmethod
302 def _test_is_gce(cls):
303 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
304 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100305 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100306 except (socket.error, httplib2.ServerNotFoundError,
307 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000308 # Could not resolve URL.
309 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100310 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000311
312 @staticmethod
313 def _get(url, **kwargs):
314 next_delay_sec = 1
315 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000316 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700317 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100318 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000319 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
320 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100321 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000322
Aaron Gable92e9f382017-12-07 11:47:41 -0800323 # Retry server error status codes.
324 LOGGER.warn('Encountered server error')
325 if TRY_LIMIT - i > 1:
326 LOGGER.info('Will retry in %d seconds (%d more times)...',
327 next_delay_sec, TRY_LIMIT - i - 1)
328 time.sleep(next_delay_sec)
329 next_delay_sec *= 2
330
331
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000332 @classmethod
333 def _get_token_dict(cls):
334 if cls._token_cache:
335 # If it expires within 25 seconds, refresh.
336 if cls._token_expiration < time.time() - 25:
337 return cls._token_cache
338
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100339 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000340 if resp.status != httplib.OK:
341 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100342 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000343 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
344 return cls._token_cache
345
346 def get_auth_header(self, _host):
347 token_dict = self._get_token_dict()
348 if not token_dict:
349 return None
350 return '%(token_type)s %(access_token)s' % token_dict
351
352
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700353class LuciContextAuthenticator(Authenticator):
354 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
355 """
356
357 @staticmethod
358 def is_luci():
359 return auth.has_luci_context_local_auth()
360
361 def __init__(self):
362 self._access_token = None
363 self._ensure_fresh()
364
365 def _ensure_fresh(self):
366 if not self._access_token or self._access_token.needs_refresh():
367 self._access_token = auth.get_luci_context_access_token(
368 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
369
370 def get_auth_header(self, _host):
371 self._ensure_fresh()
372 return 'Bearer %s' % self._access_token.token
373
374
szager@chromium.orgb4696232013-10-16 19:45:35 +0000375def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
376 """Opens an https connection to a gerrit service, and sends a request."""
377 headers = headers or {}
378 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000379
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700380 a = Authenticator.get().get_auth_header(bare_host)
381 if a:
382 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000383 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000384 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000385
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800386 url = path
387 if not url.startswith('/'):
388 url = '/' + url
389 if 'Authorization' in headers and not url.startswith('/a/'):
390 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000391
szager@chromium.orgb4696232013-10-16 19:45:35 +0000392 if body:
393 body = json.JSONEncoder().encode(body)
394 headers.setdefault('Content-Type', 'application/json')
395 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000396 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000397 for key, val in headers.iteritems():
398 if key == 'Authorization':
399 val = 'HIDDEN'
400 LOGGER.debug('%s: %s' % (key, val))
401 if body:
402 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700403 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000404 # HACK: httplib.Http has no such attribute; we store req_host here for later
405 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000406 conn.req_host = host
407 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100408 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000409 'method': reqtype,
410 'headers': headers,
411 'body': body,
412 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000413 return conn
414
415
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700416def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000417 """Reads an http response from a connection into a string buffer.
418
419 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100420 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700421 accept_statuses: Treat any of these statuses as success. Default: [200]
422 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000423 Returns: A string buffer containing the connection's reply.
424 """
Steve Kobes56117722018-09-13 18:18:35 +0000425 sleep_time = 1.5
Edward Lemur83bd7f42018-10-10 00:14:21 +0000426 failed = False
szager@chromium.orgb4696232013-10-16 19:45:35 +0000427 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100428 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000429
430 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100431 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000432 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
433 www_authenticate):
434 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
435 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700436 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000437 'has credentials for %s' % host)
438 raise GerritAuthenticationError(response.status, reason)
439
szager@chromium.orgb4696232013-10-16 19:45:35 +0000440 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000441 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700442 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000443 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700444 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100445 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100446 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700447 # If 404 was in accept_statuses, then it's expected that the file might
448 # not exist, so don't return the gitiles error page because that's not the
449 # "content" that was actually requested.
450 if response.status == 404:
451 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000452 break
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000453 if response.status == 404:
454 # TODO(crbug/881860): remove this hack.
455 # HACK: try different Gerrit mirror as a workaround for potentially
456 # out-of-date mirror hit through default routing.
457 if conn.req_host == 'chromium-review.googlesource.com':
458 conn.req_params['uri'] = _UseGerritMirror(
459 conn.req_params['uri'], 'chromium-review.googlesource.com')
460 # And don't increase sleep_time in this case, since we suspect we've
461 # just asked wrong git mirror before.
462 sleep_time /= 2.0
Edward Lemur83bd7f42018-10-10 00:14:21 +0000463 failed = True
464 rpc_headers = '\n'.join(
465 ' ' + header + ': ' + value
466 for header, value in response.iteritems()
467 if header.lower() in INTERESTING_HEADERS
468 )
469 GERRIT_ERR_LOGGER.info('Gerrit RPC failures:\n%s\n', rpc_headers)
470 else:
471 # A status >=500 is assumed to be a possible transient error; retry.
472 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
473 LOGGER.warn('A transient error occurred while querying %s:\n'
474 '%s %s %s\n'
475 '%s %d %s',
476 conn.req_host, conn.req_params['method'],
477 conn.req_params['uri'],
478 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000479
szager@chromium.orgb4696232013-10-16 19:45:35 +0000480 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800481 LOGGER.info('Will retry in %d seconds (%d more times)...',
482 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000483 time.sleep(sleep_time)
484 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000485 # end of retries loop
486
487 if failed:
Edward Lemur47faa062018-10-11 19:46:02 +0000488 LOGGER.warn(GERRIT_ERR_MESSAGE)
Aaron Gable19ee16c2017-04-18 11:56:35 -0700489 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200490 if response.status in (401, 403):
491 print('Your Gerrit credentials might be misconfigured. Try: \n'
492 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100493 reason = '%s: %s' % (response.reason, contents)
Edward Lemur47faa062018-10-11 19:46:02 +0000494 if failed:
495 reason += '\n' + GERRIT_ERR_MESSAGE
nodir@chromium.orga7798032014-04-30 23:40:53 +0000496 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100497 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000498
499
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700500def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000501 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700502 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000503 # The first line of the response should always be: )]}'
504 s = fh.readline()
505 if s and s.rstrip() != ")]}'":
506 raise GerritError(200, 'Unexpected json output: %s' % s)
507 s = fh.read()
508 if not s:
509 return None
510 return json.loads(s)
511
512
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200513def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100514 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000515 """
516 Queries a gerrit-on-borg server for changes matching query terms.
517
518 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200519 params: A list of key:value pairs for search parameters, as documented
520 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
521 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000522 first_param: A change identifier
523 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100524 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000525 o_params: A list of additional output specifiers, as documented here:
526 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
527 Returns:
528 A list of json-decoded query results.
529 """
530 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200531 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000532 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200533 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100534 if start:
535 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000536 if limit:
537 path = '%s&n=%d' % (path, limit)
538 if o_params:
539 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700540 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000541
542
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200543def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100544 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000545 """
546 Queries a gerrit-on-borg server for all the changes matching the query terms.
547
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100548 WARNING: this is unreliable if a change matching the query is modified while
549 this function is being called.
550
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000551 A single query to gerrit-on-borg is limited on the number of results by the
552 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100553 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000554
555 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200556 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000557 limit: Maximum number of requested changes per query.
558 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100559 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000560
561 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100562 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000563 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100564 already_returned = set()
565 def at_most_once(cls):
566 for cl in cls:
567 if cl['_number'] not in already_returned:
568 already_returned.add(cl['_number'])
569 yield cl
570
571 start = start or 0
572 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000573 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100574
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000575 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100576 # This will fetch changes[start..start+limit] sorted by most recently
577 # updated. Since the rank of any change in this list can be changed any time
578 # (say user posting comment), subsequent calls may overalp like this:
579 # > initial order ABCDEFGH
580 # query[0..3] => ABC
581 # > E get's updated. New order: EABCDFGH
582 # query[3..6] => CDF # C is a dup
583 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200584 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100585 cur_start)
586 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000587 yield cl
588
589 more_changes = [cl for cl in page if '_more_changes' in cl]
590 if len(more_changes) > 1:
591 raise GerritError(
592 200,
593 'Received %d changes with a _more_changes attribute set but should '
594 'receive at most one.' % len(more_changes))
595 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100596 cur_start += len(page)
597
598 # If we paged through, query again the first page which in most circumstances
599 # will fetch all changes that were modified while this function was run.
600 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200601 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100602 for cl in at_most_once(page):
603 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000604
605
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200606def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100607 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000608 """Initiate a query composed of multiple sets of query parameters."""
609 if not change_list:
610 raise RuntimeError(
611 "MultiQueryChanges requires a list of change numbers/id's")
612 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200613 if params:
614 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000615 if limit:
616 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100617 if start:
618 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000619 if o_params:
620 q.extend(['o=%s' % p for p in o_params])
621 path = 'changes/?%s' % '&'.join(q)
622 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700623 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000624 except GerritError as e:
625 msg = '%s:\n%s' % (e.message, path)
626 raise GerritError(e.http_status, msg)
627 return result
628
629
630def GetGerritFetchUrl(host):
631 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
632 return '%s://%s/' % (GERRIT_PROTOCOL, host)
633
634
635def GetChangePageUrl(host, change_number):
636 """Given a gerrit host name and change number, return change page url."""
637 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
638
639
640def GetChangeUrl(host, change):
641 """Given a gerrit host name and change id, return an url for the change."""
642 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
643
644
645def GetChange(host, change):
646 """Query a gerrit server for information about a single change."""
647 path = 'changes/%s' % change
648 return ReadHttpJsonResponse(CreateHttpConn(host, path))
649
650
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700651def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000652 """Query a gerrit server for extended information about a single change."""
653 path = 'changes/%s/detail' % change
654 if o_params:
655 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700656 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000657
658
agable32978d92016-11-01 12:55:02 -0700659def GetChangeCommit(host, change, revision='current'):
660 """Query a gerrit server for a revision associated with a change."""
661 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
662 return ReadHttpJsonResponse(CreateHttpConn(host, path))
663
664
szager@chromium.orgb4696232013-10-16 19:45:35 +0000665def GetChangeCurrentRevision(host, change):
666 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200667 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000668
669
670def GetChangeRevisions(host, change):
671 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200672 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000673
674
675def GetChangeReview(host, change, revision=None):
676 """Get the current review information for a change."""
677 if not revision:
678 jmsg = GetChangeRevisions(host, change)
679 if not jmsg:
680 return None
681 elif len(jmsg) > 1:
682 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
683 revision = jmsg[0]['current_revision']
684 path = 'changes/%s/revisions/%s/review'
685 return ReadHttpJsonResponse(CreateHttpConn(host, path))
686
687
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700688def GetChangeComments(host, change):
689 """Get the line- and file-level comments on a change."""
690 path = 'changes/%s/comments' % change
691 return ReadHttpJsonResponse(CreateHttpConn(host, path))
692
693
szager@chromium.orgb4696232013-10-16 19:45:35 +0000694def AbandonChange(host, change, msg=''):
695 """Abandon a gerrit change."""
696 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000697 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000698 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700699 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000700
701
702def RestoreChange(host, change, msg=''):
703 """Restore a previously abandoned change."""
704 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000705 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000706 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700707 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000708
709
710def SubmitChange(host, change, wait_for_merge=True):
711 """Submits a gerrit change via Gerrit."""
712 path = 'changes/%s/submit' % change
713 body = {'wait_for_merge': wait_for_merge}
714 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700715 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000716
717
dsansomee2d6fd92016-09-08 00:10:47 -0700718def HasPendingChangeEdit(host, change):
719 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
720 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700721 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700722 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700723 # 204 No Content means no pending change.
724 if e.http_status == 204:
725 return False
726 raise
727 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700728
729
730def DeletePendingChangeEdit(host, change):
731 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700732 # On success, gerrit returns status 204; if the edit was already deleted it
733 # returns 404. Anything else is an error.
734 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700735
736
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100737def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000738 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700739 assert notify in ('ALL', 'NONE')
740 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700741 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700742 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000743 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700744 ReadHttpResponse(conn, accept_statuses=[200, 204])
745 except GerritError as e:
746 raise GerritError(
747 e.http_status,
748 'Received unexpected http status while editing message '
749 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000750
751
szager@chromium.orgb4696232013-10-16 19:45:35 +0000752def GetReviewers(host, change):
753 """Get information about all reviewers attached to a change."""
754 path = 'changes/%s/reviewers' % change
755 return ReadHttpJsonResponse(CreateHttpConn(host, path))
756
757
758def GetReview(host, change, revision):
759 """Get review information about a specific revision of a change."""
760 path = 'changes/%s/revisions/%s/review' % (change, revision)
761 return ReadHttpJsonResponse(CreateHttpConn(host, path))
762
763
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700764def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
765 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000766 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700767 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800768 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200769 if not change:
770 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700771 reviewers = frozenset(reviewers or [])
772 ccs = frozenset(ccs or [])
773 path = 'changes/%s/revisions/current/review' % change
774
775 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800776 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700777 'reviewers': [],
778 'notify': 'ALL' if notify else 'NONE',
779 }
780 for r in sorted(reviewers | ccs):
781 state = 'REVIEWER' if r in reviewers else 'CC'
782 body['reviewers'].append({
783 'reviewer': r,
784 'state': state,
785 'notify': 'NONE', # We handled `notify` argument above.
786 })
787
788 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
789 # Gerrit will return 400 if one or more of the requested reviewers are
790 # unprocessable. We read the response object to see which were rejected,
791 # warn about them, and retry with the remainder.
792 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
793
794 errored = set()
795 for result in resp.get('reviewers', {}).itervalues():
796 r = result.get('input')
797 state = 'REVIEWER' if r in reviewers else 'CC'
798 if result.get('error'):
799 errored.add(r)
800 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
801 if errored:
802 # Try again, adding only those that didn't fail, and only accepting 200.
803 AddReviewers(host, change, reviewers=(reviewers-errored),
804 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000805
806
807def RemoveReviewers(host, change, remove=None):
808 """Remove reveiewers from a change."""
809 if not remove:
810 return
811 if isinstance(remove, basestring):
812 remove = (remove,)
813 for r in remove:
814 path = 'changes/%s/reviewers/%s' % (change, r)
815 conn = CreateHttpConn(host, path, reqtype='DELETE')
816 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700817 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000818 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000819 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700820 e.http_status,
821 'Received unexpected http status while deleting reviewer "%s" '
822 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000823
824
Aaron Gable636b13f2017-07-14 10:42:48 -0700825def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000826 """Set labels and/or add a message to a code review."""
827 if not msg and not labels:
828 return
829 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800830 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000831 if msg:
832 body['message'] = msg
833 if labels:
834 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700835 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700836 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700837 if ready:
838 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000839 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
840 response = ReadHttpJsonResponse(conn)
841 if labels:
842 for key, val in labels.iteritems():
843 if ('labels' not in response or key not in response['labels'] or
844 int(response['labels'][key] != int(val))):
845 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
846 key, change))
847
848
849def ResetReviewLabels(host, change, label, value='0', message=None,
850 notify=None):
851 """Reset the value of a given label for all reviewers on a change."""
852 # This is tricky, because we want to work on the "current revision", but
853 # there's always the risk that "current revision" will change in between
854 # API calls. So, we check "current revision" at the beginning and end; if
855 # it has changed, raise an exception.
856 jmsg = GetChangeCurrentRevision(host, change)
857 if not jmsg:
858 raise GerritError(
859 200, 'Could not get review information for change "%s"' % change)
860 value = str(value)
861 revision = jmsg[0]['current_revision']
862 path = 'changes/%s/revisions/%s/review' % (change, revision)
863 message = message or (
864 '%s label set to %s programmatically.' % (label, value))
865 jmsg = GetReview(host, change, revision)
866 if not jmsg:
867 raise GerritError(200, 'Could not get review information for revison %s '
868 'of change %s' % (revision, change))
869 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
870 if str(review.get('value', value)) != value:
871 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800872 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000873 'message': message,
874 'labels': {label: value},
875 'on_behalf_of': review['_account_id'],
876 }
877 if notify:
878 body['notify'] = notify
879 conn = CreateHttpConn(
880 host, path, reqtype='POST', body=body)
881 response = ReadHttpJsonResponse(conn)
882 if str(response['labels'][label]) != value:
883 username = review.get('email', jmsg.get('name', ''))
884 raise GerritError(200, 'Unable to set %s label for user "%s"'
885 ' on change %s.' % (label, username, change))
886 jmsg = GetChangeCurrentRevision(host, change)
887 if not jmsg:
888 raise GerritError(
889 200, 'Could not get review information for change "%s"' % change)
890 elif jmsg[0]['current_revision'] != revision:
891 raise GerritError(200, 'While resetting labels on change "%s", '
892 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800893
894
dimu833c94c2017-01-18 17:36:15 -0800895def CreateGerritBranch(host, project, branch, commit):
896 """
897 Create a new branch from given project and commit
898 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
899
900 Returns:
901 A JSON with 'ref' key
902 """
903 path = 'projects/%s/branches/%s' % (project, branch)
904 body = {'revision': commit}
905 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700906 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800907 if response:
908 return response
909 raise GerritError(200, 'Unable to create gerrit branch')
910
911
912def GetGerritBranch(host, project, branch):
913 """
914 Get a branch from given project and commit
915 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
916
917 Returns:
918 A JSON object with 'revision' key
919 """
920 path = 'projects/%s/branches/%s' % (project, branch)
921 conn = CreateHttpConn(host, path, reqtype='GET')
922 response = ReadHttpJsonResponse(conn)
923 if response:
924 return response
925 raise GerritError(200, 'Unable to get gerrit branch')
926
927
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100928def GetAccountDetails(host, account_id='self'):
929 """Returns details of the account.
930
931 If account_id is not given, uses magic value 'self' which corresponds to
932 whichever account user is authenticating as.
933
934 Documentation:
935 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000936
937 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100938 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100939 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000940 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
941
942
943def ValidAccounts(host, accounts, max_threads=10):
944 """Returns a mapping from valid account to its details.
945
946 Invalid accounts, either not existing or without unique match,
947 are not present as returned dictionary keys.
948 """
949 assert not isinstance(accounts, basestring), type(accounts)
950 accounts = list(set(accounts))
951 if not accounts:
952 return {}
953 def get_one(account):
954 try:
955 return account, GetAccountDetails(host, account)
956 except GerritError:
957 return None, None
958 valid = {}
959 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
960 for account, details in pool.map(get_one, accounts):
961 if account and details:
962 valid[account] = details
963 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100964
965
Nick Carter8692b182017-11-06 16:30:38 -0800966def PercentEncodeForGitRef(original):
967 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
968
969 The encoding used is based on but stricter than URL encoding (Section 2.1
970 of RFC 3986). The only non-escaped characters are alphanumerics, and
971 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
972 'PLUS SIGN' (U+002B).
973
974 For more information, see the Gerrit docs here:
975
976 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
977 """
978 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
979 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
980
981 # spaces are not allowed in git refs; gerrit will interpret either '_' or
982 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
983 return encoded.replace(' ', '_')
984
985
Dan Jacques8d11e482016-11-15 14:25:56 -0800986@contextlib.contextmanager
987def tempdir():
988 tdir = None
989 try:
990 tdir = tempfile.mkdtemp(suffix='gerrit_util')
991 yield tdir
992 finally:
993 if tdir:
994 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000995
996
997def ChangeIdentifier(project, change_number):
998 """Returns change identifier "project~number" suitable for |chagne| arg of
999 this module API.
1000
1001 Such format is allows for more efficient Gerrit routing of HTTP requests,
1002 comparing to specifying just change_number.
1003 """
1004 assert int(change_number)
1005 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001006
1007
1008# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001009_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001010assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1011
1012
1013def _UseGerritMirror(url, host):
1014 """Returns new url which uses randomly selected mirror for a gerrit host.
1015
1016 url's host should be for a given host or a result of prior call to this
1017 function.
1018
1019 Assumes url has a single occurence of the host substring.
1020 """
1021 assert host in url
1022 suffix = '-mirror-' + host
1023 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1024 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1025 st = url.find(suffix)
1026 if st == -1:
1027 actual_host = host
1028 else:
1029 # Already uses some mirror.
1030 assert st >= prefix_len, (uri, host, st, prefix_len)
1031 prefixes.remove(url[st-prefix_len:st])
1032 actual_host = url[st-prefix_len:st+len(suffix)]
1033 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)