blob: baec860c550df5bab1ca48b9bf05d95c98eb1948 [file] [log] [blame]
szager@chromium.orgb4696232013-10-16 19:45:35 +00001# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""
6Utilities for requesting information for a gerrit server via https.
7
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
11import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080012import contextlib
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000013import cookielib
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010014import httplib # Still used for its constants.
szager@chromium.orgb4696232013-10-16 19:45:35 +000015import json
16import logging
17import netrc
18import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000019import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000020import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000021import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000022import stat
23import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080024import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000025import time
26import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000027import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000028from cStringIO import StringIO
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000029from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000030
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070031import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080032import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000033import metrics
34import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080035import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010036from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000037
szager@chromium.orgb4696232013-10-16 19:45:35 +000038LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000039# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
40# total tries, the sleep time between the first and last tries will be 94.5 sec.
41# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
42TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000043
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000044
szager@chromium.orgb4696232013-10-16 19:45:35 +000045# Controls the transport protocol used to communicate with gerrit.
46# This is parameterized primarily to enable GerritTestCase.
47GERRIT_PROTOCOL = 'https'
48
49
50class GerritError(Exception):
51 """Exception class for errors commuicating with the gerrit-on-borg service."""
52 def __init__(self, http_status, *args, **kwargs):
53 super(GerritError, self).__init__(*args, **kwargs)
54 self.http_status = http_status
55 self.message = '(%d) %s' % (self.http_status, self.message)
56
57
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000058class GerritAuthenticationError(GerritError):
59 """Exception class for authentication errors during Gerrit communication."""
60
61
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020062def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000063 """Encodes query parameters in the key:val[+key:val...] format specified here:
64
65 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
66 """
67 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020068 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000069 return '+'.join(q)
70
71
Aaron Gabled2db5a22017-03-24 14:14:15 -070072def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000073 if protocol is None:
74 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010075 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070076 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000077 else:
78 raise RuntimeError(
79 "Don't know how to work with protocol '%s'" % protocol)
80
81
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000082class Authenticator(object):
83 """Base authenticator class for authenticator implementations to subclass."""
84
85 def get_auth_header(self, host):
86 raise NotImplementedError()
87
88 @staticmethod
89 def get():
90 """Returns: (Authenticator) The identified Authenticator to use.
91
92 Probes the local system and its environment and identifies the
93 Authenticator instance to use.
94 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070095 # LUCI Context takes priority since it's normally present only on bots,
96 # which then must use it.
97 if LuciContextAuthenticator.is_luci():
98 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000099 if GceAuthenticator.is_gce():
100 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000101 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000102
103
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000104class CookiesAuthenticator(Authenticator):
105 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
106
107 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000108 """
109
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000110 _EMPTY = object()
111
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000112 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000113 # Credentials will be loaded lazily on first use. This ensures Authenticator
114 # get() can always construct an authenticator, even if something is broken.
115 # This allows 'creds-check' to proceed to actually checking creds later,
116 # rigorously (instead of blowing up with a cryptic error if they are wrong).
117 self._netrc = self._EMPTY
118 self._gitcookies = self._EMPTY
119
120 @property
121 def netrc(self):
122 if self._netrc is self._EMPTY:
123 self._netrc = self._get_netrc()
124 return self._netrc
125
126 @property
127 def gitcookies(self):
128 if self._gitcookies is self._EMPTY:
129 self._gitcookies = self._get_gitcookies()
130 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000131
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000132 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200133 def get_new_password_url(cls, host):
134 assert not host.startswith('http')
135 # Assume *.googlesource.com pattern.
136 parts = host.split('.')
137 if not parts[0].endswith('-review'):
138 parts[0] += '-review'
139 return 'https://%s/new-password' % ('.'.join(parts))
140
141 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000142 def get_new_password_message(cls, host):
William Hessee9e89e32019-03-03 19:02:32 +0000143 if host is None:
144 return ('Git host for gerrit upload is unknown. Check your remote '
145 'and the branch your branch is tracking. This tool assumes '
146 'that you are using a git server at *.googlesource.com.')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000147 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 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100153 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000154
155 @classmethod
156 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000157 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000158 return os.path.expanduser(os.path.join('~', path))
159
160 @classmethod
161 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800162 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000163 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000164 if not os.path.exists(path):
165 return netrc.netrc(os.devnull)
166
167 st = os.stat(path)
168 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
169 print >> sys.stderr, (
170 'WARNING: netrc file %s cannot be used because its file '
171 'permissions are insecure. netrc file permissions should be '
172 '600.' % path)
173 with open(path) as fd:
174 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800175
176 # Load the '.netrc' file. We strip comments from it because processing them
177 # can trigger a bug in Windows. See crbug.com/664664.
178 content = '\n'.join(l for l in content.splitlines()
179 if l.strip() and not l.strip().startswith('#'))
180 with tempdir() as tdir:
181 netrc_path = os.path.join(tdir, 'netrc')
182 with open(netrc_path, 'w') as fd:
183 fd.write(content)
184 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
185 return cls._get_netrc_from_path(netrc_path)
186
187 @classmethod
188 def _get_netrc_from_path(cls, path):
189 try:
190 return netrc.netrc(path)
191 except IOError:
192 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
193 return netrc.netrc(os.devnull)
194 except netrc.NetrcParseError as e:
195 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
196 'parsing error: %s' % (path, e))
197 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000198
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000199 @classmethod
200 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500201 if os.getenv('GIT_COOKIES_PATH'):
202 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800203 try:
204 return subprocess2.check_output(
205 ['git', 'config', '--path', 'http.cookiefile']).strip()
206 except subprocess2.CalledProcessError:
207 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000208
209 @classmethod
210 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000211 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000212 path = cls.get_gitcookies_path()
213 if not os.path.exists(path):
214 return gitcookies
215
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000216 try:
217 f = open(path, 'rb')
218 except IOError:
219 return gitcookies
220
221 with f:
222 for line in f:
223 try:
224 fields = line.strip().split('\t')
225 if line.strip().startswith('#') or len(fields) != 7:
226 continue
227 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
228 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000229 if value.startswith('git-'):
230 login, secret_token = value.split('=', 1)
231 gitcookies[domain] = (login, secret_token)
232 else:
233 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000234 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100235 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000236 return gitcookies
237
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100238 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000239 for domain, creds in self.gitcookies.iteritems():
240 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100241 return (creds[0], None, creds[1])
242 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000243
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100244 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700245 a = self._get_auth_for_host(host)
246 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000247 if a[0]:
248 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
249 else:
250 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000251 return None
252
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100253 def get_auth_email(self, host):
254 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700255 a = self._get_auth_for_host(host)
256 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100257 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700258 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100259 # login typically looks like 'git-xxx.example.com'
260 if not login.startswith('git-') or '.' not in login:
261 return None
262 username, domain = login[len('git-'):].split('.', 1)
263 return '%s@%s' % (username, domain)
264
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100265
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000266# Backwards compatibility just in case somebody imports this outside of
267# depot_tools.
268NetrcAuthenticator = CookiesAuthenticator
269
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000270
271class GceAuthenticator(Authenticator):
272 """Authenticator implementation that uses GCE metadata service for token.
273 """
274
275 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700276 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
277 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000278 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
279
280 _cache_is_gce = None
281 _token_cache = None
282 _token_expiration = None
283
284 @classmethod
285 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500286 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
287 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000288 if cls._cache_is_gce is None:
289 cls._cache_is_gce = cls._test_is_gce()
290 return cls._cache_is_gce
291
292 @classmethod
293 def _test_is_gce(cls):
294 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
295 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100296 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100297 except (socket.error, httplib2.ServerNotFoundError,
298 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000299 # Could not resolve URL.
300 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100301 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000302
303 @staticmethod
304 def _get(url, **kwargs):
305 next_delay_sec = 1
306 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000307 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700308 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100309 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000310 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
311 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100312 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000313
Aaron Gable92e9f382017-12-07 11:47:41 -0800314 # Retry server error status codes.
315 LOGGER.warn('Encountered server error')
316 if TRY_LIMIT - i > 1:
317 LOGGER.info('Will retry in %d seconds (%d more times)...',
318 next_delay_sec, TRY_LIMIT - i - 1)
319 time.sleep(next_delay_sec)
320 next_delay_sec *= 2
321
322
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000323 @classmethod
324 def _get_token_dict(cls):
325 if cls._token_cache:
326 # If it expires within 25 seconds, refresh.
327 if cls._token_expiration < time.time() - 25:
328 return cls._token_cache
329
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100330 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000331 if resp.status != httplib.OK:
332 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100333 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000334 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
335 return cls._token_cache
336
337 def get_auth_header(self, _host):
338 token_dict = self._get_token_dict()
339 if not token_dict:
340 return None
341 return '%(token_type)s %(access_token)s' % token_dict
342
343
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700344class LuciContextAuthenticator(Authenticator):
345 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
346 """
347
348 @staticmethod
349 def is_luci():
350 return auth.has_luci_context_local_auth()
351
352 def __init__(self):
353 self._access_token = None
354 self._ensure_fresh()
355
356 def _ensure_fresh(self):
357 if not self._access_token or self._access_token.needs_refresh():
358 self._access_token = auth.get_luci_context_access_token(
359 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
360
361 def get_auth_header(self, _host):
362 self._ensure_fresh()
363 return 'Bearer %s' % self._access_token.token
364
365
szager@chromium.orgb4696232013-10-16 19:45:35 +0000366def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
367 """Opens an https connection to a gerrit service, and sends a request."""
368 headers = headers or {}
369 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000370
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700371 a = Authenticator.get().get_auth_header(bare_host)
372 if a:
373 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000374 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000375 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000376
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800377 url = path
378 if not url.startswith('/'):
379 url = '/' + url
380 if 'Authorization' in headers and not url.startswith('/a/'):
381 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000382
szager@chromium.orgb4696232013-10-16 19:45:35 +0000383 if body:
384 body = json.JSONEncoder().encode(body)
385 headers.setdefault('Content-Type', 'application/json')
386 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000387 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000388 for key, val in headers.iteritems():
389 if key == 'Authorization':
390 val = 'HIDDEN'
391 LOGGER.debug('%s: %s' % (key, val))
392 if body:
393 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700394 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000395 # HACK: httplib.Http has no such attribute; we store req_host here for later
396 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000397 conn.req_host = host
398 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100399 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000400 'method': reqtype,
401 'headers': headers,
402 'body': body,
403 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000404 return conn
405
406
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700407def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000408 """Reads an http response from a connection into a string buffer.
409
410 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100411 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700412 accept_statuses: Treat any of these statuses as success. Default: [200]
413 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000414 Returns: A string buffer containing the connection's reply.
415 """
Steve Kobes56117722018-09-13 18:18:35 +0000416 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000417 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000418 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100419 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000420
Edward Lemur5a9ff432018-10-30 19:00:22 +0000421 response_time = time.time() - before_response
422 metrics.collector.add_repeated(
423 'http_requests',
424 metrics_utils.extract_http_metrics(
425 conn.req_params['uri'], conn.req_params['method'], response.status,
426 response_time))
427
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000428 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100429 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000430 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
431 www_authenticate):
432 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
433 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700434 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000435 'has credentials for %s' % host)
436 raise GerritAuthenticationError(response.status, reason)
437
szager@chromium.orgb4696232013-10-16 19:45:35 +0000438 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000439 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700440 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000441 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700442 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100443 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100444 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700445 # If 404 was in accept_statuses, then it's expected that the file might
446 # not exist, so don't return the gitiles error page because that's not the
447 # "content" that was actually requested.
448 if response.status == 404:
449 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000450 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000451 # A status >=500 is assumed to be a possible transient error; retry.
452 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
453 LOGGER.warn('A transient error occurred while querying %s:\n'
454 '%s %s %s\n'
455 '%s %d %s',
456 conn.req_host, conn.req_params['method'],
457 conn.req_params['uri'],
458 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000459 if response.status == 404:
460 # TODO(crbug/881860): remove this hack.
461 # HACK: try different Gerrit mirror as a workaround for potentially
462 # out-of-date mirror hit through default routing.
463 if conn.req_host == 'chromium-review.googlesource.com':
464 conn.req_params['uri'] = _UseGerritMirror(
465 conn.req_params['uri'], 'chromium-review.googlesource.com')
466 # And don't increase sleep_time in this case, since we suspect we've
467 # just asked wrong git mirror before.
468 sleep_time /= 2.0
469
szager@chromium.orgb4696232013-10-16 19:45:35 +0000470 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800471 LOGGER.info('Will retry in %d seconds (%d more times)...',
472 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000473 time.sleep(sleep_time)
474 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000475 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700476 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200477 if response.status in (401, 403):
478 print('Your Gerrit credentials might be misconfigured. Try: \n'
479 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100480 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000481 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100482 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000483
484
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700485def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000486 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700487 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000488 # The first line of the response should always be: )]}'
489 s = fh.readline()
490 if s and s.rstrip() != ")]}'":
491 raise GerritError(200, 'Unexpected json output: %s' % s)
492 s = fh.read()
493 if not s:
494 return None
495 return json.loads(s)
496
497
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200498def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000500 """
501 Queries a gerrit-on-borg server for changes matching query terms.
502
503 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200504 params: A list of key:value pairs for search parameters, as documented
505 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
506 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000507 first_param: A change identifier
508 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100509 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000510 o_params: A list of additional output specifiers, as documented here:
511 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
512 Returns:
513 A list of json-decoded query results.
514 """
515 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200516 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000517 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200518 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100519 if start:
520 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000521 if limit:
522 path = '%s&n=%d' % (path, limit)
523 if o_params:
524 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700525 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000526
527
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200528def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100529 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000530 """
531 Queries a gerrit-on-borg server for all the changes matching the query terms.
532
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100533 WARNING: this is unreliable if a change matching the query is modified while
534 this function is being called.
535
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000536 A single query to gerrit-on-borg is limited on the number of results by the
537 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100538 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000539
540 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200541 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000542 limit: Maximum number of requested changes per query.
543 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100544 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000545
546 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100547 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000548 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100549 already_returned = set()
550 def at_most_once(cls):
551 for cl in cls:
552 if cl['_number'] not in already_returned:
553 already_returned.add(cl['_number'])
554 yield cl
555
556 start = start or 0
557 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000558 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100559
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000560 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100561 # This will fetch changes[start..start+limit] sorted by most recently
562 # updated. Since the rank of any change in this list can be changed any time
563 # (say user posting comment), subsequent calls may overalp like this:
564 # > initial order ABCDEFGH
565 # query[0..3] => ABC
566 # > E get's updated. New order: EABCDFGH
567 # query[3..6] => CDF # C is a dup
568 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200569 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100570 cur_start)
571 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000572 yield cl
573
574 more_changes = [cl for cl in page if '_more_changes' in cl]
575 if len(more_changes) > 1:
576 raise GerritError(
577 200,
578 'Received %d changes with a _more_changes attribute set but should '
579 'receive at most one.' % len(more_changes))
580 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100581 cur_start += len(page)
582
583 # If we paged through, query again the first page which in most circumstances
584 # will fetch all changes that were modified while this function was run.
585 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200586 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100587 for cl in at_most_once(page):
588 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000589
590
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200591def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100592 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000593 """Initiate a query composed of multiple sets of query parameters."""
594 if not change_list:
595 raise RuntimeError(
596 "MultiQueryChanges requires a list of change numbers/id's")
597 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200598 if params:
599 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000600 if limit:
601 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100602 if start:
603 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000604 if o_params:
605 q.extend(['o=%s' % p for p in o_params])
606 path = 'changes/?%s' % '&'.join(q)
607 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700608 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000609 except GerritError as e:
610 msg = '%s:\n%s' % (e.message, path)
611 raise GerritError(e.http_status, msg)
612 return result
613
614
615def GetGerritFetchUrl(host):
616 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
617 return '%s://%s/' % (GERRIT_PROTOCOL, host)
618
619
Edward Lemur687ca902018-12-05 02:30:30 +0000620def GetCodeReviewTbrScore(host, project):
621 """Given a gerrit host name and project, return the Code-Review score for TBR.
622 """
623 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
624 project = ReadHttpJsonResponse(conn)
625 if ('labels' not in project
626 or 'Code-Review' not in project['labels']
627 or 'values' not in project['labels']['Code-Review']):
628 return 1
629 return max([int(x) for x in project['labels']['Code-Review']['values']])
630
631
szager@chromium.orgb4696232013-10-16 19:45:35 +0000632def GetChangePageUrl(host, change_number):
633 """Given a gerrit host name and change number, return change page url."""
634 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
635
636
637def GetChangeUrl(host, change):
638 """Given a gerrit host name and change id, return an url for the change."""
639 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
640
641
642def GetChange(host, change):
643 """Query a gerrit server for information about a single change."""
644 path = 'changes/%s' % change
645 return ReadHttpJsonResponse(CreateHttpConn(host, path))
646
647
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700648def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000649 """Query a gerrit server for extended information about a single change."""
650 path = 'changes/%s/detail' % change
651 if o_params:
652 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700653 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000654
655
agable32978d92016-11-01 12:55:02 -0700656def GetChangeCommit(host, change, revision='current'):
657 """Query a gerrit server for a revision associated with a change."""
658 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
659 return ReadHttpJsonResponse(CreateHttpConn(host, path))
660
661
szager@chromium.orgb4696232013-10-16 19:45:35 +0000662def GetChangeCurrentRevision(host, change):
663 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200664 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000665
666
667def GetChangeRevisions(host, change):
668 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200669 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000670
671
672def GetChangeReview(host, change, revision=None):
673 """Get the current review information for a change."""
674 if not revision:
675 jmsg = GetChangeRevisions(host, change)
676 if not jmsg:
677 return None
678 elif len(jmsg) > 1:
679 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
680 revision = jmsg[0]['current_revision']
681 path = 'changes/%s/revisions/%s/review'
682 return ReadHttpJsonResponse(CreateHttpConn(host, path))
683
684
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700685def GetChangeComments(host, change):
686 """Get the line- and file-level comments on a change."""
687 path = 'changes/%s/comments' % change
688 return ReadHttpJsonResponse(CreateHttpConn(host, path))
689
690
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000691def GetChangeRobotComments(host, change):
692 """Get the line- and file-level robot comments on a change."""
693 path = 'changes/%s/robotcomments' % change
694 return ReadHttpJsonResponse(CreateHttpConn(host, path))
695
696
szager@chromium.orgb4696232013-10-16 19:45:35 +0000697def AbandonChange(host, change, msg=''):
698 """Abandon a gerrit change."""
699 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000700 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000701 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700702 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000703
704
705def RestoreChange(host, change, msg=''):
706 """Restore a previously abandoned change."""
707 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000708 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000709 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700710 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000711
712
713def SubmitChange(host, change, wait_for_merge=True):
714 """Submits a gerrit change via Gerrit."""
715 path = 'changes/%s/submit' % change
716 body = {'wait_for_merge': wait_for_merge}
717 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700718 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000719
720
dsansomee2d6fd92016-09-08 00:10:47 -0700721def HasPendingChangeEdit(host, change):
722 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
723 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700724 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700725 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700726 # 204 No Content means no pending change.
727 if e.http_status == 204:
728 return False
729 raise
730 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700731
732
733def DeletePendingChangeEdit(host, change):
734 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700735 # On success, gerrit returns status 204; if the edit was already deleted it
736 # returns 404. Anything else is an error.
737 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700738
739
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100740def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000741 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700742 assert notify in ('ALL', 'NONE')
743 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700744 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700745 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000746 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700747 ReadHttpResponse(conn, accept_statuses=[200, 204])
748 except GerritError as e:
749 raise GerritError(
750 e.http_status,
751 'Received unexpected http status while editing message '
752 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000753
754
szager@chromium.orgb4696232013-10-16 19:45:35 +0000755def GetReviewers(host, change):
756 """Get information about all reviewers attached to a change."""
757 path = 'changes/%s/reviewers' % change
758 return ReadHttpJsonResponse(CreateHttpConn(host, path))
759
760
761def GetReview(host, change, revision):
762 """Get review information about a specific revision of a change."""
763 path = 'changes/%s/revisions/%s/review' % (change, revision)
764 return ReadHttpJsonResponse(CreateHttpConn(host, path))
765
766
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700767def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
768 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000769 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700770 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800771 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200772 if not change:
773 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700774 reviewers = frozenset(reviewers or [])
775 ccs = frozenset(ccs or [])
776 path = 'changes/%s/revisions/current/review' % change
777
778 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800779 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700780 'reviewers': [],
781 'notify': 'ALL' if notify else 'NONE',
782 }
783 for r in sorted(reviewers | ccs):
784 state = 'REVIEWER' if r in reviewers else 'CC'
785 body['reviewers'].append({
786 'reviewer': r,
787 'state': state,
788 'notify': 'NONE', # We handled `notify` argument above.
789 })
790
791 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
792 # Gerrit will return 400 if one or more of the requested reviewers are
793 # unprocessable. We read the response object to see which were rejected,
794 # warn about them, and retry with the remainder.
795 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
796
797 errored = set()
798 for result in resp.get('reviewers', {}).itervalues():
799 r = result.get('input')
800 state = 'REVIEWER' if r in reviewers else 'CC'
801 if result.get('error'):
802 errored.add(r)
803 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
804 if errored:
805 # Try again, adding only those that didn't fail, and only accepting 200.
806 AddReviewers(host, change, reviewers=(reviewers-errored),
807 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000808
809
810def RemoveReviewers(host, change, remove=None):
811 """Remove reveiewers from a change."""
812 if not remove:
813 return
814 if isinstance(remove, basestring):
815 remove = (remove,)
816 for r in remove:
817 path = 'changes/%s/reviewers/%s' % (change, r)
818 conn = CreateHttpConn(host, path, reqtype='DELETE')
819 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700820 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000821 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000822 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700823 e.http_status,
824 'Received unexpected http status while deleting reviewer "%s" '
825 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000826
827
Aaron Gable636b13f2017-07-14 10:42:48 -0700828def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000829 """Set labels and/or add a message to a code review."""
830 if not msg and not labels:
831 return
832 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800833 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000834 if msg:
835 body['message'] = msg
836 if labels:
837 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700838 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700839 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700840 if ready:
841 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000842 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
843 response = ReadHttpJsonResponse(conn)
844 if labels:
845 for key, val in labels.iteritems():
846 if ('labels' not in response or key not in response['labels'] or
847 int(response['labels'][key] != int(val))):
848 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
849 key, change))
850
851
852def ResetReviewLabels(host, change, label, value='0', message=None,
853 notify=None):
854 """Reset the value of a given label for all reviewers on a change."""
855 # This is tricky, because we want to work on the "current revision", but
856 # there's always the risk that "current revision" will change in between
857 # API calls. So, we check "current revision" at the beginning and end; if
858 # it has changed, raise an exception.
859 jmsg = GetChangeCurrentRevision(host, change)
860 if not jmsg:
861 raise GerritError(
862 200, 'Could not get review information for change "%s"' % change)
863 value = str(value)
864 revision = jmsg[0]['current_revision']
865 path = 'changes/%s/revisions/%s/review' % (change, revision)
866 message = message or (
867 '%s label set to %s programmatically.' % (label, value))
868 jmsg = GetReview(host, change, revision)
869 if not jmsg:
870 raise GerritError(200, 'Could not get review information for revison %s '
871 'of change %s' % (revision, change))
872 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
873 if str(review.get('value', value)) != value:
874 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800875 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000876 'message': message,
877 'labels': {label: value},
878 'on_behalf_of': review['_account_id'],
879 }
880 if notify:
881 body['notify'] = notify
882 conn = CreateHttpConn(
883 host, path, reqtype='POST', body=body)
884 response = ReadHttpJsonResponse(conn)
885 if str(response['labels'][label]) != value:
886 username = review.get('email', jmsg.get('name', ''))
887 raise GerritError(200, 'Unable to set %s label for user "%s"'
888 ' on change %s.' % (label, username, change))
889 jmsg = GetChangeCurrentRevision(host, change)
890 if not jmsg:
891 raise GerritError(
892 200, 'Could not get review information for change "%s"' % change)
893 elif jmsg[0]['current_revision'] != revision:
894 raise GerritError(200, 'While resetting labels on change "%s", '
895 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800896
897
dimu833c94c2017-01-18 17:36:15 -0800898def CreateGerritBranch(host, project, branch, commit):
899 """
900 Create a new branch from given project and commit
901 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
902
903 Returns:
904 A JSON with 'ref' key
905 """
906 path = 'projects/%s/branches/%s' % (project, branch)
907 body = {'revision': commit}
908 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700909 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800910 if response:
911 return response
912 raise GerritError(200, 'Unable to create gerrit branch')
913
914
915def GetGerritBranch(host, project, branch):
916 """
917 Get a branch from given project and commit
918 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
919
920 Returns:
921 A JSON object with 'revision' key
922 """
923 path = 'projects/%s/branches/%s' % (project, branch)
924 conn = CreateHttpConn(host, path, reqtype='GET')
925 response = ReadHttpJsonResponse(conn)
926 if response:
927 return response
928 raise GerritError(200, 'Unable to get gerrit branch')
929
930
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100931def GetAccountDetails(host, account_id='self'):
932 """Returns details of the account.
933
934 If account_id is not given, uses magic value 'self' which corresponds to
935 whichever account user is authenticating as.
936
937 Documentation:
938 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000939
940 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100941 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100942 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000943 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
944
945
946def ValidAccounts(host, accounts, max_threads=10):
947 """Returns a mapping from valid account to its details.
948
949 Invalid accounts, either not existing or without unique match,
950 are not present as returned dictionary keys.
951 """
952 assert not isinstance(accounts, basestring), type(accounts)
953 accounts = list(set(accounts))
954 if not accounts:
955 return {}
956 def get_one(account):
957 try:
958 return account, GetAccountDetails(host, account)
959 except GerritError:
960 return None, None
961 valid = {}
962 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
963 for account, details in pool.map(get_one, accounts):
964 if account and details:
965 valid[account] = details
966 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100967
968
Nick Carter8692b182017-11-06 16:30:38 -0800969def PercentEncodeForGitRef(original):
970 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
971
972 The encoding used is based on but stricter than URL encoding (Section 2.1
973 of RFC 3986). The only non-escaped characters are alphanumerics, and
974 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
975 'PLUS SIGN' (U+002B).
976
977 For more information, see the Gerrit docs here:
978
979 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
980 """
981 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
982 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
983
984 # spaces are not allowed in git refs; gerrit will interpret either '_' or
985 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
986 return encoded.replace(' ', '_')
987
988
Dan Jacques8d11e482016-11-15 14:25:56 -0800989@contextlib.contextmanager
990def tempdir():
991 tdir = None
992 try:
993 tdir = tempfile.mkdtemp(suffix='gerrit_util')
994 yield tdir
995 finally:
996 if tdir:
997 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000998
999
1000def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +00001001 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001002 this module API.
1003
1004 Such format is allows for more efficient Gerrit routing of HTTP requests,
1005 comparing to specifying just change_number.
1006 """
1007 assert int(change_number)
1008 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001009
1010
1011# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001012_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001013assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1014
1015
1016def _UseGerritMirror(url, host):
1017 """Returns new url which uses randomly selected mirror for a gerrit host.
1018
1019 url's host should be for a given host or a result of prior call to this
1020 function.
1021
1022 Assumes url has a single occurence of the host substring.
1023 """
1024 assert host in url
1025 suffix = '-mirror-' + host
1026 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1027 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1028 st = url.find(suffix)
1029 if st == -1:
1030 actual_host = host
1031 else:
1032 # Already uses some mirror.
1033 assert st >= prefix_len, (uri, host, st, prefix_len)
1034 prefixes.remove(url[st-prefix_len:st])
1035 actual_host = url[st-prefix_len:st+len(suffix)]
1036 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)