blob: fe296d3f89e5120cd5bfa4ec18b9d0ef0b6010fd [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"""
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00006Utilities for requesting information for a Gerrit server via HTTPS.
szager@chromium.orgb4696232013-10-16 19:45:35 +00007
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
Raul Tambre80ee78e2019-05-06 22:41:05 +000011from __future__ import print_function
12
szager@chromium.orgb4696232013-10-16 19:45:35 +000013import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080014import contextlib
Edward Lemur202c5592019-10-21 22:44:52 +000015import httplib2
szager@chromium.orgb4696232013-10-16 19:45:35 +000016import json
17import logging
18import netrc
19import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000020import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000021import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000022import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000023import stat
24import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080025import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000026import time
27import urllib
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000028from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000029
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070030import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080031import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000032import metrics
33import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080034import subprocess2
szager@chromium.orgf202a252014-05-27 18:55:52 +000035
Edward Lemura834f392019-10-22 22:23:00 +000036if sys.version_info.major == 2:
37 import cookielib
38 import httplib
39 import urlparse
40 from cStringIO import StringIO
41else:
42 import http.cookiejar as cookielib
43 import http.client as httplib
44 import urllib.parse as urlparse
45 from io import StringIO
46
szager@chromium.orgb4696232013-10-16 19:45:35 +000047LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000048# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
49# total tries, the sleep time between the first and last tries will be 94.5 sec.
Edward Lemurb1ae4812019-10-23 04:52:47 +000050TRY_LIMIT = 3
szager@chromium.orgb4696232013-10-16 19:45:35 +000051
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000052
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +000053# Controls the transport protocol used to communicate with Gerrit.
szager@chromium.orgb4696232013-10-16 19:45:35 +000054# This is parameterized primarily to enable GerritTestCase.
55GERRIT_PROTOCOL = 'https'
56
57
58class GerritError(Exception):
59 """Exception class for errors commuicating with the gerrit-on-borg service."""
60 def __init__(self, http_status, *args, **kwargs):
61 super(GerritError, self).__init__(*args, **kwargs)
62 self.http_status = http_status
63 self.message = '(%d) %s' % (self.http_status, self.message)
64
65
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000066class GerritAuthenticationError(GerritError):
67 """Exception class for authentication errors during Gerrit communication."""
68
69
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020070def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000071 """Encodes query parameters in the key:val[+key:val...] format specified here:
72
73 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
74 """
75 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020076 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000077 return '+'.join(q)
78
79
Aaron Gabled2db5a22017-03-24 14:14:15 -070080def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000081 if protocol is None:
82 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010083 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070084 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000085 else:
86 raise RuntimeError(
87 "Don't know how to work with protocol '%s'" % protocol)
88
89
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000090class Authenticator(object):
91 """Base authenticator class for authenticator implementations to subclass."""
92
93 def get_auth_header(self, host):
94 raise NotImplementedError()
95
96 @staticmethod
97 def get():
98 """Returns: (Authenticator) The identified Authenticator to use.
99
100 Probes the local system and its environment and identifies the
101 Authenticator instance to use.
102 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700103 # LUCI Context takes priority since it's normally present only on bots,
104 # which then must use it.
105 if LuciContextAuthenticator.is_luci():
106 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000107 if GceAuthenticator.is_gce():
108 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000109 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000110
111
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000112class CookiesAuthenticator(Authenticator):
113 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
114
115 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000116 """
117
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000118 _EMPTY = object()
119
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000120 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000121 # Credentials will be loaded lazily on first use. This ensures Authenticator
122 # get() can always construct an authenticator, even if something is broken.
123 # This allows 'creds-check' to proceed to actually checking creds later,
124 # rigorously (instead of blowing up with a cryptic error if they are wrong).
125 self._netrc = self._EMPTY
126 self._gitcookies = self._EMPTY
127
128 @property
129 def netrc(self):
130 if self._netrc is self._EMPTY:
131 self._netrc = self._get_netrc()
132 return self._netrc
133
134 @property
135 def gitcookies(self):
136 if self._gitcookies is self._EMPTY:
137 self._gitcookies = self._get_gitcookies()
138 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000139
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000140 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200141 def get_new_password_url(cls, host):
142 assert not host.startswith('http')
143 # Assume *.googlesource.com pattern.
144 parts = host.split('.')
145 if not parts[0].endswith('-review'):
146 parts[0] += '-review'
147 return 'https://%s/new-password' % ('.'.join(parts))
148
149 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000150 def get_new_password_message(cls, host):
William Hessee9e89e32019-03-03 19:02:32 +0000151 if host is None:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000152 return ('Git host for Gerrit upload is unknown. Check your remote '
William Hessee9e89e32019-03-03 19:02:32 +0000153 'and the branch your branch is tracking. This tool assumes '
154 'that you are using a git server at *.googlesource.com.')
Edward Lemur67fccdf2019-10-22 22:17:10 +0000155 url = cls.get_new_password_url(host)
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100156 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000157
158 @classmethod
159 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000160 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000161 return os.path.expanduser(os.path.join('~', path))
162
163 @classmethod
164 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800165 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000166 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000167 if not os.path.exists(path):
168 return netrc.netrc(os.devnull)
169
170 st = os.stat(path)
171 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000172 print(
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000173 'WARNING: netrc file %s cannot be used because its file '
174 'permissions are insecure. netrc file permissions should be '
Raul Tambre80ee78e2019-05-06 22:41:05 +0000175 '600.' % path, file=sys.stderr)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000176 with open(path) as fd:
177 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800178
179 # Load the '.netrc' file. We strip comments from it because processing them
180 # can trigger a bug in Windows. See crbug.com/664664.
181 content = '\n'.join(l for l in content.splitlines()
182 if l.strip() and not l.strip().startswith('#'))
183 with tempdir() as tdir:
184 netrc_path = os.path.join(tdir, 'netrc')
185 with open(netrc_path, 'w') as fd:
186 fd.write(content)
187 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
188 return cls._get_netrc_from_path(netrc_path)
189
190 @classmethod
191 def _get_netrc_from_path(cls, path):
192 try:
193 return netrc.netrc(path)
194 except IOError:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000195 print('WARNING: Could not read netrc file %s' % path, file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800196 return netrc.netrc(os.devnull)
197 except netrc.NetrcParseError as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000198 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
199 (path, e), file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800200 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000201
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000202 @classmethod
203 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500204 if os.getenv('GIT_COOKIES_PATH'):
205 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800206 try:
207 return subprocess2.check_output(
208 ['git', 'config', '--path', 'http.cookiefile']).strip()
209 except subprocess2.CalledProcessError:
210 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000211
212 @classmethod
213 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000214 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000215 path = cls.get_gitcookies_path()
216 if not os.path.exists(path):
217 return gitcookies
218
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000219 try:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000220 f = gclient_utils.FileRead(path, 'rb').splitlines()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000221 except IOError:
222 return gitcookies
223
Edward Lemur67fccdf2019-10-22 22:17:10 +0000224 for line in f:
225 try:
226 fields = line.strip().split('\t')
227 if line.strip().startswith('#') or len(fields) != 7:
228 continue
229 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
230 if xpath == '/' and key == 'o':
231 if value.startswith('git-'):
232 login, secret_token = value.split('=', 1)
233 gitcookies[domain] = (login, secret_token)
234 else:
235 gitcookies[domain] = ('', value)
236 except (IndexError, ValueError, TypeError) as exc:
237 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000238 return gitcookies
239
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100240 def _get_auth_for_host(self, host):
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000241 for domain, creds in self.gitcookies.items():
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000242 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100243 return (creds[0], None, creds[1])
244 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000245
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100246 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700247 a = self._get_auth_for_host(host)
248 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000249 if a[0]:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000250 secret = base64.b64encode(('%s:%s' % (a[0], a[2])).encode('utf-8'))
251 return 'Basic %s' % secret.decode('utf-8')
Eric Boren2fb63102018-10-05 13:05:03 +0000252 else:
253 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000254 return None
255
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100256 def get_auth_email(self, host):
257 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700258 a = self._get_auth_for_host(host)
259 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100260 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700261 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100262 # login typically looks like 'git-xxx.example.com'
263 if not login.startswith('git-') or '.' not in login:
264 return None
265 username, domain = login[len('git-'):].split('.', 1)
266 return '%s@%s' % (username, domain)
267
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100268
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000269# Backwards compatibility just in case somebody imports this outside of
270# depot_tools.
271NetrcAuthenticator = CookiesAuthenticator
272
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000273
274class GceAuthenticator(Authenticator):
275 """Authenticator implementation that uses GCE metadata service for token.
276 """
277
278 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700279 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
280 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000281 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
282
283 _cache_is_gce = None
284 _token_cache = None
285 _token_expiration = None
286
287 @classmethod
288 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500289 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
290 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000291 if cls._cache_is_gce is None:
292 cls._cache_is_gce = cls._test_is_gce()
293 return cls._cache_is_gce
294
295 @classmethod
296 def _test_is_gce(cls):
297 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
298 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100299 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100300 except (socket.error, httplib2.ServerNotFoundError,
301 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000302 # Could not resolve URL.
303 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100304 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000305
306 @staticmethod
307 def _get(url, **kwargs):
308 next_delay_sec = 1
309 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000310 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700311 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100312 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000313 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
314 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100315 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000316
Aaron Gable92e9f382017-12-07 11:47:41 -0800317 # Retry server error status codes.
318 LOGGER.warn('Encountered server error')
319 if TRY_LIMIT - i > 1:
320 LOGGER.info('Will retry in %d seconds (%d more times)...',
321 next_delay_sec, TRY_LIMIT - i - 1)
322 time.sleep(next_delay_sec)
323 next_delay_sec *= 2
324
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000325 @classmethod
326 def _get_token_dict(cls):
327 if cls._token_cache:
328 # If it expires within 25 seconds, refresh.
329 if cls._token_expiration < time.time() - 25:
330 return cls._token_cache
331
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100332 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000333 if resp.status != httplib.OK:
334 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100335 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000336 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
337 return cls._token_cache
338
339 def get_auth_header(self, _host):
340 token_dict = self._get_token_dict()
341 if not token_dict:
342 return None
343 return '%(token_type)s %(access_token)s' % token_dict
344
345
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700346class LuciContextAuthenticator(Authenticator):
347 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
348 """
349
350 @staticmethod
351 def is_luci():
352 return auth.has_luci_context_local_auth()
353
354 def __init__(self):
Edward Lemur5b929a42019-10-21 17:57:39 +0000355 self._authenticator = auth.Authenticator(
356 ' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700357
358 def get_auth_header(self, _host):
Edward Lemur5b929a42019-10-21 17:57:39 +0000359 return 'Bearer %s' % self._authenticator.get_access_token().token
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700360
361
szager@chromium.orgb4696232013-10-16 19:45:35 +0000362def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000363 """Opens an HTTPS connection to a Gerrit service, and sends a request."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000364 headers = headers or {}
365 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000366
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700367 a = Authenticator.get().get_auth_header(bare_host)
368 if a:
369 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000370 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000371 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000372
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800373 url = path
374 if not url.startswith('/'):
375 url = '/' + url
376 if 'Authorization' in headers and not url.startswith('/a/'):
377 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000378
szager@chromium.orgb4696232013-10-16 19:45:35 +0000379 if body:
380 body = json.JSONEncoder().encode(body)
381 headers.setdefault('Content-Type', 'application/json')
382 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000383 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000384 for key, val in headers.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000385 if key == 'Authorization':
386 val = 'HIDDEN'
387 LOGGER.debug('%s: %s' % (key, val))
388 if body:
389 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700390 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000391 # HACK: httplib.Http has no such attribute; we store req_host here for later
392 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000393 conn.req_host = host
394 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100395 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000396 'method': reqtype,
397 'headers': headers,
398 'body': body,
399 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000400 return conn
401
402
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700403def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000404 """Reads an HTTP response from a connection into a string buffer.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000405
406 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100407 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700408 accept_statuses: Treat any of these statuses as success. Default: [200]
409 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000410 Returns: A string buffer containing the connection's reply.
411 """
Steve Kobes56117722018-09-13 18:18:35 +0000412 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000413 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000414 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100415 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000416
Edward Lemur5a9ff432018-10-30 19:00:22 +0000417 response_time = time.time() - before_response
418 metrics.collector.add_repeated(
419 'http_requests',
420 metrics_utils.extract_http_metrics(
421 conn.req_params['uri'], conn.req_params['method'], response.status,
422 response_time))
423
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000424 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100425 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000426 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
427 www_authenticate):
428 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
429 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700430 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000431 'has credentials for %s' % host)
432 raise GerritAuthenticationError(response.status, reason)
433
szager@chromium.orgb4696232013-10-16 19:45:35 +0000434 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000435 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700436 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000437 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700438 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100439 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100440 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700441 # If 404 was in accept_statuses, then it's expected that the file might
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000442 # not exist, so don't return the gitiles error page because that's not
443 # the "content" that was actually requested.
Michael Mossb40a4512017-10-10 11:07:17 -0700444 if response.status == 404:
445 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000446 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000447 # A status >=500 is assumed to be a possible transient error; retry.
448 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
449 LOGGER.warn('A transient error occurred while querying %s:\n'
450 '%s %s %s\n'
451 '%s %d %s',
452 conn.req_host, conn.req_params['method'],
453 conn.req_params['uri'],
454 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000455
szager@chromium.orgb4696232013-10-16 19:45:35 +0000456 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800457 LOGGER.info('Will retry in %d seconds (%d more times)...',
458 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000459 time.sleep(sleep_time)
460 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000461 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700462 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200463 if response.status in (401, 403):
464 print('Your Gerrit credentials might be misconfigured. Try: \n'
465 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100466 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000467 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100468 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000469
470
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700471def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000472 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700473 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000474 # The first line of the response should always be: )]}'
475 s = fh.readline()
476 if s and s.rstrip() != ")]}'":
477 raise GerritError(200, 'Unexpected json output: %s' % s)
478 s = fh.read()
479 if not s:
480 return None
481 return json.loads(s)
482
483
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200484def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100485 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000486 """
487 Queries a gerrit-on-borg server for changes matching query terms.
488
489 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200490 params: A list of key:value pairs for search parameters, as documented
491 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
492 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000493 first_param: A change identifier
494 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100495 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000496 o_params: A list of additional output specifiers, as documented here:
497 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000498
szager@chromium.orgb4696232013-10-16 19:45:35 +0000499 Returns:
500 A list of json-decoded query results.
501 """
502 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200503 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000504 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200505 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100506 if start:
507 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000508 if limit:
509 path = '%s&n=%d' % (path, limit)
510 if o_params:
511 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700512 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000513
514
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200515def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100516 o_params=None, start=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000517 """Queries a gerrit-on-borg server for all the changes matching the query
518 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000519
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100520 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000521 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100522
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000523 A single query to gerrit-on-borg is limited on the number of results by the
524 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100525 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000526
527 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200528 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000529 limit: Maximum number of requested changes per query.
530 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000532
533 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100534 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000535 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100536 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000537
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100538 def at_most_once(cls):
539 for cl in cls:
540 if cl['_number'] not in already_returned:
541 already_returned.add(cl['_number'])
542 yield cl
543
544 start = start or 0
545 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000546 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100547
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000548 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100549 # This will fetch changes[start..start+limit] sorted by most recently
550 # updated. Since the rank of any change in this list can be changed any time
551 # (say user posting comment), subsequent calls may overalp like this:
552 # > initial order ABCDEFGH
553 # query[0..3] => ABC
554 # > E get's updated. New order: EABCDFGH
555 # query[3..6] => CDF # C is a dup
556 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200557 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100558 cur_start)
559 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000560 yield cl
561
562 more_changes = [cl for cl in page if '_more_changes' in cl]
563 if len(more_changes) > 1:
564 raise GerritError(
565 200,
566 'Received %d changes with a _more_changes attribute set but should '
567 'receive at most one.' % len(more_changes))
568 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100569 cur_start += len(page)
570
571 # If we paged through, query again the first page which in most circumstances
572 # will fetch all changes that were modified while this function was run.
573 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200574 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100575 for cl in at_most_once(page):
576 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000577
578
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200579def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100580 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000581 """Initiate a query composed of multiple sets of query parameters."""
582 if not change_list:
583 raise RuntimeError(
584 "MultiQueryChanges requires a list of change numbers/id's")
585 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200586 if params:
587 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000588 if limit:
589 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100590 if start:
591 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000592 if o_params:
593 q.extend(['o=%s' % p for p in o_params])
594 path = 'changes/?%s' % '&'.join(q)
595 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700596 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000597 except GerritError as e:
598 msg = '%s:\n%s' % (e.message, path)
599 raise GerritError(e.http_status, msg)
600 return result
601
602
603def GetGerritFetchUrl(host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000604 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000605 return '%s://%s/' % (GERRIT_PROTOCOL, host)
606
607
Edward Lemur687ca902018-12-05 02:30:30 +0000608def GetCodeReviewTbrScore(host, project):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000609 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000610 """
611 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
612 project = ReadHttpJsonResponse(conn)
613 if ('labels' not in project
614 or 'Code-Review' not in project['labels']
615 or 'values' not in project['labels']['Code-Review']):
616 return 1
617 return max([int(x) for x in project['labels']['Code-Review']['values']])
618
619
szager@chromium.orgb4696232013-10-16 19:45:35 +0000620def GetChangePageUrl(host, change_number):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000621 """Given a Gerrit host name and change number, returns change page URL."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000622 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
623
624
625def GetChangeUrl(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000626 """Given a Gerrit host name and change ID, returns a URL for the change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000627 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
628
629
630def GetChange(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000631 """Queries a Gerrit server for information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000632 path = 'changes/%s' % change
633 return ReadHttpJsonResponse(CreateHttpConn(host, path))
634
635
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700636def GetChangeDetail(host, change, o_params=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000637 """Queries a Gerrit server for extended information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000638 path = 'changes/%s/detail' % change
639 if o_params:
640 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700641 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000642
643
agable32978d92016-11-01 12:55:02 -0700644def GetChangeCommit(host, change, revision='current'):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000645 """Query a Gerrit server for a revision associated with a change."""
agable32978d92016-11-01 12:55:02 -0700646 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
647 return ReadHttpJsonResponse(CreateHttpConn(host, path))
648
649
szager@chromium.orgb4696232013-10-16 19:45:35 +0000650def GetChangeCurrentRevision(host, change):
651 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200652 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000653
654
655def GetChangeRevisions(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000656 """Gets information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200657 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000658
659
660def GetChangeReview(host, change, revision=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000661 """Gets the current review information for a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000662 if not revision:
663 jmsg = GetChangeRevisions(host, change)
664 if not jmsg:
665 return None
666 elif len(jmsg) > 1:
667 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
668 revision = jmsg[0]['current_revision']
669 path = 'changes/%s/revisions/%s/review'
670 return ReadHttpJsonResponse(CreateHttpConn(host, path))
671
672
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700673def GetChangeComments(host, change):
674 """Get the line- and file-level comments on a change."""
675 path = 'changes/%s/comments' % change
676 return ReadHttpJsonResponse(CreateHttpConn(host, path))
677
678
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000679def GetChangeRobotComments(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000680 """Gets the line- and file-level robot comments on a change."""
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000681 path = 'changes/%s/robotcomments' % change
682 return ReadHttpJsonResponse(CreateHttpConn(host, path))
683
684
szager@chromium.orgb4696232013-10-16 19:45:35 +0000685def AbandonChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000686 """Abandons a Gerrit change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000687 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000688 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000689 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700690 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000691
692
693def RestoreChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000694 """Restores a previously abandoned change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000695 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000696 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000697 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700698 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000699
700
701def SubmitChange(host, change, wait_for_merge=True):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000702 """Submits a Gerrit change via Gerrit."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000703 path = 'changes/%s/submit' % change
704 body = {'wait_for_merge': wait_for_merge}
705 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700706 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000707
708
dsansomee2d6fd92016-09-08 00:10:47 -0700709def HasPendingChangeEdit(host, change):
710 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
711 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700712 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700713 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700714 # 204 No Content means no pending change.
715 if e.http_status == 204:
716 return False
717 raise
718 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700719
720
721def DeletePendingChangeEdit(host, change):
722 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000723 # On success, Gerrit returns status 204; if the edit was already deleted it
Aaron Gable19ee16c2017-04-18 11:56:35 -0700724 # returns 404. Anything else is an error.
725 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700726
727
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100728def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000729 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700730 assert notify in ('ALL', 'NONE')
731 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700732 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700733 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000734 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700735 ReadHttpResponse(conn, accept_statuses=[200, 204])
736 except GerritError as e:
737 raise GerritError(
738 e.http_status,
739 'Received unexpected http status while editing message '
740 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000741
742
szager@chromium.orgb4696232013-10-16 19:45:35 +0000743def GetReviewers(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000744 """Gets information about all reviewers attached to a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000745 path = 'changes/%s/reviewers' % change
746 return ReadHttpJsonResponse(CreateHttpConn(host, path))
747
748
749def GetReview(host, change, revision):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000750 """Gets review information about a specific revision of a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000751 path = 'changes/%s/revisions/%s/review' % (change, revision)
752 return ReadHttpJsonResponse(CreateHttpConn(host, path))
753
754
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700755def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
756 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000757 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700758 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800759 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200760 if not change:
761 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700762 reviewers = frozenset(reviewers or [])
763 ccs = frozenset(ccs or [])
764 path = 'changes/%s/revisions/current/review' % change
765
766 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800767 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700768 'reviewers': [],
769 'notify': 'ALL' if notify else 'NONE',
770 }
771 for r in sorted(reviewers | ccs):
772 state = 'REVIEWER' if r in reviewers else 'CC'
773 body['reviewers'].append({
774 'reviewer': r,
775 'state': state,
776 'notify': 'NONE', # We handled `notify` argument above.
Raul Tambre80ee78e2019-05-06 22:41:05 +0000777 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700778
779 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
780 # Gerrit will return 400 if one or more of the requested reviewers are
781 # unprocessable. We read the response object to see which were rejected,
782 # warn about them, and retry with the remainder.
783 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
784
785 errored = set()
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000786 for result in resp.get('reviewers', {}).values():
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700787 r = result.get('input')
788 state = 'REVIEWER' if r in reviewers else 'CC'
789 if result.get('error'):
790 errored.add(r)
791 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
792 if errored:
793 # Try again, adding only those that didn't fail, and only accepting 200.
794 AddReviewers(host, change, reviewers=(reviewers-errored),
795 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000796
797
798def RemoveReviewers(host, change, remove=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000799 """Removes reviewers from a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000800 if not remove:
801 return
802 if isinstance(remove, basestring):
803 remove = (remove,)
804 for r in remove:
805 path = 'changes/%s/reviewers/%s' % (change, r)
806 conn = CreateHttpConn(host, path, reqtype='DELETE')
807 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700808 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000809 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000810 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700811 e.http_status,
812 'Received unexpected http status while deleting reviewer "%s" '
813 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000814
815
Aaron Gable636b13f2017-07-14 10:42:48 -0700816def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000817 """Sets labels and/or adds a message to a code review."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000818 if not msg and not labels:
819 return
820 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800821 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000822 if msg:
823 body['message'] = msg
824 if labels:
825 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700826 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700827 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700828 if ready:
829 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000830 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
831 response = ReadHttpJsonResponse(conn)
832 if labels:
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000833 for key, val in labels.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000834 if ('labels' not in response or key not in response['labels'] or
835 int(response['labels'][key] != int(val))):
836 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
837 key, change))
838
839
840def ResetReviewLabels(host, change, label, value='0', message=None,
841 notify=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000842 """Resets the value of a given label for all reviewers on a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000843 # This is tricky, because we want to work on the "current revision", but
844 # there's always the risk that "current revision" will change in between
845 # API calls. So, we check "current revision" at the beginning and end; if
846 # it has changed, raise an exception.
847 jmsg = GetChangeCurrentRevision(host, change)
848 if not jmsg:
849 raise GerritError(
850 200, 'Could not get review information for change "%s"' % change)
851 value = str(value)
852 revision = jmsg[0]['current_revision']
853 path = 'changes/%s/revisions/%s/review' % (change, revision)
854 message = message or (
855 '%s label set to %s programmatically.' % (label, value))
856 jmsg = GetReview(host, change, revision)
857 if not jmsg:
858 raise GerritError(200, 'Could not get review information for revison %s '
859 'of change %s' % (revision, change))
860 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
861 if str(review.get('value', value)) != value:
862 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800863 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000864 'message': message,
865 'labels': {label: value},
866 'on_behalf_of': review['_account_id'],
867 }
868 if notify:
869 body['notify'] = notify
870 conn = CreateHttpConn(
871 host, path, reqtype='POST', body=body)
872 response = ReadHttpJsonResponse(conn)
873 if str(response['labels'][label]) != value:
874 username = review.get('email', jmsg.get('name', ''))
875 raise GerritError(200, 'Unable to set %s label for user "%s"'
876 ' on change %s.' % (label, username, change))
877 jmsg = GetChangeCurrentRevision(host, change)
878 if not jmsg:
879 raise GerritError(
880 200, 'Could not get review information for change "%s"' % change)
881 elif jmsg[0]['current_revision'] != revision:
882 raise GerritError(200, 'While resetting labels on change "%s", '
883 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800884
885
dimu833c94c2017-01-18 17:36:15 -0800886def CreateGerritBranch(host, project, branch, commit):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000887 """Creates a new branch from given project and commit
888
dimu833c94c2017-01-18 17:36:15 -0800889 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
890
891 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000892 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -0800893 """
894 path = 'projects/%s/branches/%s' % (project, branch)
895 body = {'revision': commit}
896 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700897 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800898 if response:
899 return response
900 raise GerritError(200, 'Unable to create gerrit branch')
901
902
903def GetGerritBranch(host, project, branch):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000904 """Gets a branch from given project and commit.
905
906 See:
dimu833c94c2017-01-18 17:36:15 -0800907 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
908
909 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000910 A JSON object with 'revision' key.
dimu833c94c2017-01-18 17:36:15 -0800911 """
912 path = 'projects/%s/branches/%s' % (project, branch)
913 conn = CreateHttpConn(host, path, reqtype='GET')
914 response = ReadHttpJsonResponse(conn)
915 if response:
916 return response
917 raise GerritError(200, 'Unable to get gerrit branch')
918
919
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100920def GetAccountDetails(host, account_id='self'):
921 """Returns details of the account.
922
923 If account_id is not given, uses magic value 'self' which corresponds to
924 whichever account user is authenticating as.
925
926 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000927 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000928
929 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100930 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100931 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000932 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
933
934
935def ValidAccounts(host, accounts, max_threads=10):
936 """Returns a mapping from valid account to its details.
937
938 Invalid accounts, either not existing or without unique match,
939 are not present as returned dictionary keys.
940 """
941 assert not isinstance(accounts, basestring), type(accounts)
942 accounts = list(set(accounts))
943 if not accounts:
944 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000945
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000946 def get_one(account):
947 try:
948 return account, GetAccountDetails(host, account)
949 except GerritError:
950 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000951
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000952 valid = {}
953 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
954 for account, details in pool.map(get_one, accounts):
955 if account and details:
956 valid[account] = details
957 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100958
959
Nick Carter8692b182017-11-06 16:30:38 -0800960def PercentEncodeForGitRef(original):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000961 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -0800962
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000963 The encoding used is based on but stricter than URL encoding (Section 2.1 of
964 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
965 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -0800966
967 For more information, see the Gerrit docs here:
968
969 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
970 """
971 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
972 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
973
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000974 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
Nick Carter8692b182017-11-06 16:30:38 -0800975 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
976 return encoded.replace(' ', '_')
977
978
Dan Jacques8d11e482016-11-15 14:25:56 -0800979@contextlib.contextmanager
980def tempdir():
981 tdir = None
982 try:
983 tdir = tempfile.mkdtemp(suffix='gerrit_util')
984 yield tdir
985 finally:
986 if tdir:
987 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000988
989
990def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +0000991 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000992 this module API.
993
994 Such format is allows for more efficient Gerrit routing of HTTP requests,
995 comparing to specifying just change_number.
996 """
997 assert int(change_number)
998 return '%s~%s' % (urllib.quote(project, safe=''), change_number)