blob: dc33142602259b6af08a360dcba1543783f547e0 [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
Edward Lemur5bfa3ae2019-10-25 22:18:40 +000012from __future__ import unicode_literals
Raul Tambre80ee78e2019-05-06 22:41:05 +000013
szager@chromium.orgb4696232013-10-16 19:45:35 +000014import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080015import contextlib
Edward Lemur202c5592019-10-21 22:44:52 +000016import httplib2
szager@chromium.orgb4696232013-10-16 19:45:35 +000017import json
18import logging
19import netrc
20import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000021import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000022import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000023import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000024import stat
25import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080026import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000027import time
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 Lemur5bfa3ae2019-10-25 22:18:40 +000036from third_party import six
37from six.moves import urllib
38
39if sys.version_info.major == 2:
40 import cookielib
41 from cStringIO import StringIO
42else:
43 import http.cookiejar as cookielib
44 from io import StringIO
45
szager@chromium.orgb4696232013-10-16 19:45:35 +000046LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000047# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
48# total tries, the sleep time between the first and last tries will be 94.5 sec.
Edward Lemurb1ae4812019-10-23 04:52:47 +000049TRY_LIMIT = 3
szager@chromium.orgb4696232013-10-16 19:45:35 +000050
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000051
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +000052# Controls the transport protocol used to communicate with Gerrit.
szager@chromium.orgb4696232013-10-16 19:45:35 +000053# This is parameterized primarily to enable GerritTestCase.
54GERRIT_PROTOCOL = 'https'
55
56
Edward Lemur5bfa3ae2019-10-25 22:18:40 +000057def time_sleep(seconds):
58 # Use this so that it can be mocked in tests without interfering with python
59 # system machinery.
60 return time.sleep(seconds)
61
62
szager@chromium.orgb4696232013-10-16 19:45:35 +000063class GerritError(Exception):
64 """Exception class for errors commuicating with the gerrit-on-borg service."""
Edward Lemur5bfa3ae2019-10-25 22:18:40 +000065 def __init__(self, http_status, message, *args, **kwargs):
szager@chromium.orgb4696232013-10-16 19:45:35 +000066 super(GerritError, self).__init__(*args, **kwargs)
67 self.http_status = http_status
Edward Lemur5bfa3ae2019-10-25 22:18:40 +000068 self.message = '(%d) %s' % (self.http_status, message)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000069
70
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020071def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000072 """Encodes query parameters in the key:val[+key:val...] format specified here:
73
74 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
75 """
Edward Lemur5bfa3ae2019-10-25 22:18:40 +000076 q = [urllib.parse.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020077 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000078 return '+'.join(q)
79
80
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000081class Authenticator(object):
82 """Base authenticator class for authenticator implementations to subclass."""
83
84 def get_auth_header(self, host):
85 raise NotImplementedError()
86
87 @staticmethod
88 def get():
89 """Returns: (Authenticator) The identified Authenticator to use.
90
91 Probes the local system and its environment and identifies the
92 Authenticator instance to use.
93 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070094 # LUCI Context takes priority since it's normally present only on bots,
95 # which then must use it.
96 if LuciContextAuthenticator.is_luci():
97 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000098 if GceAuthenticator.is_gce():
99 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000100 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000101
102
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000103class CookiesAuthenticator(Authenticator):
104 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
105
106 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000107 """
108
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000109 _EMPTY = object()
110
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000111 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000112 # Credentials will be loaded lazily on first use. This ensures Authenticator
113 # get() can always construct an authenticator, even if something is broken.
114 # This allows 'creds-check' to proceed to actually checking creds later,
115 # rigorously (instead of blowing up with a cryptic error if they are wrong).
116 self._netrc = self._EMPTY
117 self._gitcookies = self._EMPTY
118
119 @property
120 def netrc(self):
121 if self._netrc is self._EMPTY:
122 self._netrc = self._get_netrc()
123 return self._netrc
124
125 @property
126 def gitcookies(self):
127 if self._gitcookies is self._EMPTY:
128 self._gitcookies = self._get_gitcookies()
129 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000130
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000131 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200132 def get_new_password_url(cls, host):
133 assert not host.startswith('http')
134 # Assume *.googlesource.com pattern.
135 parts = host.split('.')
136 if not parts[0].endswith('-review'):
137 parts[0] += '-review'
138 return 'https://%s/new-password' % ('.'.join(parts))
139
140 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000141 def get_new_password_message(cls, host):
William Hessee9e89e32019-03-03 19:02:32 +0000142 if host is None:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000143 return ('Git host for Gerrit upload is unknown. Check your remote '
William Hessee9e89e32019-03-03 19:02:32 +0000144 'and the branch your branch is tracking. This tool assumes '
145 'that you are using a git server at *.googlesource.com.')
Edward Lemur67fccdf2019-10-22 22:17:10 +0000146 url = cls.get_new_password_url(host)
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100147 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000148
149 @classmethod
150 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000151 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000152 return os.path.expanduser(os.path.join('~', path))
153
154 @classmethod
155 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800156 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000157 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000158 if not os.path.exists(path):
159 return netrc.netrc(os.devnull)
160
161 st = os.stat(path)
162 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000163 print(
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000164 'WARNING: netrc file %s cannot be used because its file '
165 'permissions are insecure. netrc file permissions should be '
Raul Tambre80ee78e2019-05-06 22:41:05 +0000166 '600.' % path, file=sys.stderr)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000167 with open(path) as fd:
168 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800169
170 # Load the '.netrc' file. We strip comments from it because processing them
171 # can trigger a bug in Windows. See crbug.com/664664.
172 content = '\n'.join(l for l in content.splitlines()
173 if l.strip() and not l.strip().startswith('#'))
174 with tempdir() as tdir:
175 netrc_path = os.path.join(tdir, 'netrc')
176 with open(netrc_path, 'w') as fd:
177 fd.write(content)
178 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
179 return cls._get_netrc_from_path(netrc_path)
180
181 @classmethod
182 def _get_netrc_from_path(cls, path):
183 try:
184 return netrc.netrc(path)
185 except IOError:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000186 print('WARNING: Could not read netrc file %s' % path, file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800187 return netrc.netrc(os.devnull)
188 except netrc.NetrcParseError as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000189 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
190 (path, e), file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800191 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000192
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000193 @classmethod
194 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500195 if os.getenv('GIT_COOKIES_PATH'):
196 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800197 try:
198 return subprocess2.check_output(
199 ['git', 'config', '--path', 'http.cookiefile']).strip()
200 except subprocess2.CalledProcessError:
201 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000202
203 @classmethod
204 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000205 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000206 path = cls.get_gitcookies_path()
207 if not os.path.exists(path):
208 return gitcookies
209
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000210 try:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000211 f = gclient_utils.FileRead(path, 'rb').splitlines()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000212 except IOError:
213 return gitcookies
214
Edward Lemur67fccdf2019-10-22 22:17:10 +0000215 for line in f:
216 try:
217 fields = line.strip().split('\t')
218 if line.strip().startswith('#') or len(fields) != 7:
219 continue
220 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
221 if xpath == '/' and key == 'o':
222 if value.startswith('git-'):
223 login, secret_token = value.split('=', 1)
224 gitcookies[domain] = (login, secret_token)
225 else:
226 gitcookies[domain] = ('', value)
227 except (IndexError, ValueError, TypeError) as exc:
228 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000229 return gitcookies
230
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100231 def _get_auth_for_host(self, host):
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000232 for domain, creds in self.gitcookies.items():
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000233 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100234 return (creds[0], None, creds[1])
235 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000236
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100237 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700238 a = self._get_auth_for_host(host)
239 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000240 if a[0]:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000241 secret = base64.b64encode(('%s:%s' % (a[0], a[2])).encode('utf-8'))
242 return 'Basic %s' % secret.decode('utf-8')
Eric Boren2fb63102018-10-05 13:05:03 +0000243 else:
244 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000245 return None
246
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100247 def get_auth_email(self, host):
248 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700249 a = self._get_auth_for_host(host)
250 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100251 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700252 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100253 # login typically looks like 'git-xxx.example.com'
254 if not login.startswith('git-') or '.' not in login:
255 return None
256 username, domain = login[len('git-'):].split('.', 1)
257 return '%s@%s' % (username, domain)
258
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100259
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000260# Backwards compatibility just in case somebody imports this outside of
261# depot_tools.
262NetrcAuthenticator = CookiesAuthenticator
263
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000264
265class GceAuthenticator(Authenticator):
266 """Authenticator implementation that uses GCE metadata service for token.
267 """
268
269 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700270 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
271 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000272 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
273
274 _cache_is_gce = None
275 _token_cache = None
276 _token_expiration = None
277
278 @classmethod
279 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500280 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
281 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000282 if cls._cache_is_gce is None:
283 cls._cache_is_gce = cls._test_is_gce()
284 return cls._cache_is_gce
285
286 @classmethod
287 def _test_is_gce(cls):
288 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
289 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100290 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100291 except (socket.error, httplib2.ServerNotFoundError,
292 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000293 # Could not resolve URL.
294 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100295 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000296
297 @staticmethod
298 def _get(url, **kwargs):
299 next_delay_sec = 1
300 for i in xrange(TRY_LIMIT):
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000301 p = urllib.parse.urlparse(url)
302 if p.scheme not in ('http', 'https'):
303 raise RuntimeError(
304 "Don't know how to work with protocol '%s'" % protocol)
305 resp, contents = httplib2.Http().request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000306 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000307 if resp.status < 500:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100308 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000309
Aaron Gable92e9f382017-12-07 11:47:41 -0800310 # Retry server error status codes.
311 LOGGER.warn('Encountered server error')
312 if TRY_LIMIT - i > 1:
313 LOGGER.info('Will retry in %d seconds (%d more times)...',
314 next_delay_sec, TRY_LIMIT - i - 1)
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000315 time_sleep(next_delay_sec)
Aaron Gable92e9f382017-12-07 11:47:41 -0800316 next_delay_sec *= 2
317
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000318 @classmethod
319 def _get_token_dict(cls):
320 if cls._token_cache:
321 # If it expires within 25 seconds, refresh.
322 if cls._token_expiration < time.time() - 25:
323 return cls._token_cache
324
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100325 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000326 if resp.status != 200:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000327 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100328 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000329 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
330 return cls._token_cache
331
332 def get_auth_header(self, _host):
333 token_dict = self._get_token_dict()
334 if not token_dict:
335 return None
336 return '%(token_type)s %(access_token)s' % token_dict
337
338
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700339class LuciContextAuthenticator(Authenticator):
340 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
341 """
342
343 @staticmethod
344 def is_luci():
345 return auth.has_luci_context_local_auth()
346
347 def __init__(self):
Edward Lemur5b929a42019-10-21 17:57:39 +0000348 self._authenticator = auth.Authenticator(
349 ' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700350
351 def get_auth_header(self, _host):
Edward Lemur5b929a42019-10-21 17:57:39 +0000352 return 'Bearer %s' % self._authenticator.get_access_token().token
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700353
354
szager@chromium.orgb4696232013-10-16 19:45:35 +0000355def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000356 """Opens an HTTPS connection to a Gerrit service, and sends a request."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000357 headers = headers or {}
358 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000359
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700360 a = Authenticator.get().get_auth_header(bare_host)
361 if a:
362 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000363 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000364 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000365
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800366 url = path
367 if not url.startswith('/'):
368 url = '/' + url
369 if 'Authorization' in headers and not url.startswith('/a/'):
370 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000371
szager@chromium.orgb4696232013-10-16 19:45:35 +0000372 if body:
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000373 body = json.dumps(body, sort_keys=True)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000374 headers.setdefault('Content-Type', 'application/json')
375 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000376 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000377 for key, val in headers.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000378 if key == 'Authorization':
379 val = 'HIDDEN'
380 LOGGER.debug('%s: %s' % (key, val))
381 if body:
382 LOGGER.debug(body)
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000383 conn = httplib2.Http()
384 # HACK: httplib2.Http has no such attribute; we store req_host here for later
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000385 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000386 conn.req_host = host
387 conn.req_params = {
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000388 'uri': urllib.parse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000389 'method': reqtype,
390 'headers': headers,
391 'body': body,
392 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000393 return conn
394
395
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700396def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000397 """Reads an HTTP response from a connection into a string buffer.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000398
399 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100400 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700401 accept_statuses: Treat any of these statuses as success. Default: [200]
402 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000403 Returns: A string buffer containing the connection's reply.
404 """
Steve Kobes56117722018-09-13 18:18:35 +0000405 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000406 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000407 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100408 response, contents = conn.request(**conn.req_params)
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000409 contents = contents.decode('utf-8', 'replace')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000410
Edward Lemur5a9ff432018-10-30 19:00:22 +0000411 response_time = time.time() - before_response
412 metrics.collector.add_repeated(
413 'http_requests',
414 metrics_utils.extract_http_metrics(
415 conn.req_params['uri'], conn.req_params['method'], response.status,
416 response_time))
417
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000418 # If response.status is an accepted status,
419 # or response.status < 500 then the result is final; break retry loop.
420 # If the response is 404/409 it might be because of replication lag,
421 # so keep trying anyway.
422 if (response.status in accept_statuses
423 or response.status < 500 and response.status not in [404, 409]):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100424 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100425 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700426 # If 404 was in accept_statuses, then it's expected that the file might
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000427 # not exist, so don't return the gitiles error page because that's not
428 # the "content" that was actually requested.
Michael Mossb40a4512017-10-10 11:07:17 -0700429 if response.status == 404:
430 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000431 break
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000432
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000433 # A status >=500 is assumed to be a possible transient error; retry.
434 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
435 LOGGER.warn('A transient error occurred while querying %s:\n'
436 '%s %s %s\n'
437 '%s %d %s',
438 conn.req_host, conn.req_params['method'],
439 conn.req_params['uri'],
440 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000441
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000442 if idx < TRY_LIMIT - 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800443 LOGGER.info('Will retry in %d seconds (%d more times)...',
444 sleep_time, TRY_LIMIT - idx - 1)
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000445 time_sleep(sleep_time)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000446 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000447 # end of retries loop
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000448
449 if response.status in accept_statuses:
450 return StringIO(contents)
451
452 if response.status in (302, 401, 403):
453 www_authenticate = response.get('www-authenticate')
454 if not www_authenticate:
455 print('Your Gerrit credentials might be misconfigured.')
456 else:
457 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
458 host = auth_match.group(1) if auth_match else conn.req_host
459 print('Authentication failed. Please make sure your .gitcookies '
460 'file has credentials for %s.' % host)
461 print('Try:\n git cl creds-check')
462
463 reason = '%s: %s' % (response.reason, contents)
464 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000465
466
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700467def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000468 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700469 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000470 # The first line of the response should always be: )]}'
471 s = fh.readline()
472 if s and s.rstrip() != ")]}'":
473 raise GerritError(200, 'Unexpected json output: %s' % s)
474 s = fh.read()
475 if not s:
476 return None
477 return json.loads(s)
478
479
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200480def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100481 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000482 """
483 Queries a gerrit-on-borg server for changes matching query terms.
484
485 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200486 params: A list of key:value pairs for search parameters, as documented
487 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
488 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000489 first_param: A change identifier
490 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100491 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000492 o_params: A list of additional output specifiers, as documented here:
493 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000494
szager@chromium.orgb4696232013-10-16 19:45:35 +0000495 Returns:
496 A list of json-decoded query results.
497 """
498 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200499 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000500 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200501 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100502 if start:
503 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000504 if limit:
505 path = '%s&n=%d' % (path, limit)
506 if o_params:
507 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700508 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000509
510
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200511def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100512 o_params=None, start=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000513 """Queries a gerrit-on-borg server for all the changes matching the query
514 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000515
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100516 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000517 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100518
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000519 A single query to gerrit-on-borg is limited on the number of results by the
520 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100521 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000522
523 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200524 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000525 limit: Maximum number of requested changes per query.
526 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100527 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000528
529 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100530 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000531 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100532 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000533
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100534 def at_most_once(cls):
535 for cl in cls:
536 if cl['_number'] not in already_returned:
537 already_returned.add(cl['_number'])
538 yield cl
539
540 start = start or 0
541 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000542 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100543
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000544 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100545 # This will fetch changes[start..start+limit] sorted by most recently
546 # updated. Since the rank of any change in this list can be changed any time
547 # (say user posting comment), subsequent calls may overalp like this:
548 # > initial order ABCDEFGH
549 # query[0..3] => ABC
550 # > E get's updated. New order: EABCDFGH
551 # query[3..6] => CDF # C is a dup
552 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200553 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100554 cur_start)
555 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000556 yield cl
557
558 more_changes = [cl for cl in page if '_more_changes' in cl]
559 if len(more_changes) > 1:
560 raise GerritError(
561 200,
562 'Received %d changes with a _more_changes attribute set but should '
563 'receive at most one.' % len(more_changes))
564 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100565 cur_start += len(page)
566
567 # If we paged through, query again the first page which in most circumstances
568 # will fetch all changes that were modified while this function was run.
569 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200570 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100571 for cl in at_most_once(page):
572 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000573
574
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200575def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100576 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000577 """Initiate a query composed of multiple sets of query parameters."""
578 if not change_list:
579 raise RuntimeError(
580 "MultiQueryChanges requires a list of change numbers/id's")
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000581 q = ['q=%s' % '+OR+'.join([urllib.parse.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200582 if params:
583 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000584 if limit:
585 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100586 if start:
587 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000588 if o_params:
589 q.extend(['o=%s' % p for p in o_params])
590 path = 'changes/?%s' % '&'.join(q)
591 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700592 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000593 except GerritError as e:
594 msg = '%s:\n%s' % (e.message, path)
595 raise GerritError(e.http_status, msg)
596 return result
597
598
599def GetGerritFetchUrl(host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000600 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000601 return '%s://%s/' % (GERRIT_PROTOCOL, host)
602
603
Edward Lemur687ca902018-12-05 02:30:30 +0000604def GetCodeReviewTbrScore(host, project):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000605 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000606 """
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000607 conn = CreateHttpConn(
608 host, '/projects/%s' % urllib.parse.quote(project, ''))
Edward Lemur687ca902018-12-05 02:30:30 +0000609 project = ReadHttpJsonResponse(conn)
610 if ('labels' not in project
611 or 'Code-Review' not in project['labels']
612 or 'values' not in project['labels']['Code-Review']):
613 return 1
614 return max([int(x) for x in project['labels']['Code-Review']['values']])
615
616
szager@chromium.orgb4696232013-10-16 19:45:35 +0000617def GetChangePageUrl(host, change_number):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000618 """Given a Gerrit host name and change number, returns change page URL."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000619 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
620
621
622def GetChangeUrl(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000623 """Given a Gerrit host name and change ID, returns a URL for the change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000624 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
625
626
627def GetChange(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000628 """Queries a Gerrit server for information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000629 path = 'changes/%s' % change
630 return ReadHttpJsonResponse(CreateHttpConn(host, path))
631
632
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700633def GetChangeDetail(host, change, o_params=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000634 """Queries a Gerrit server for extended information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000635 path = 'changes/%s/detail' % change
636 if o_params:
637 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700638 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000639
640
agable32978d92016-11-01 12:55:02 -0700641def GetChangeCommit(host, change, revision='current'):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000642 """Query a Gerrit server for a revision associated with a change."""
agable32978d92016-11-01 12:55:02 -0700643 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
644 return ReadHttpJsonResponse(CreateHttpConn(host, path))
645
646
szager@chromium.orgb4696232013-10-16 19:45:35 +0000647def GetChangeCurrentRevision(host, change):
648 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200649 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000650
651
652def GetChangeRevisions(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000653 """Gets information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200654 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000655
656
657def GetChangeReview(host, change, revision=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000658 """Gets the current review information for a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000659 if not revision:
660 jmsg = GetChangeRevisions(host, change)
661 if not jmsg:
662 return None
663 elif len(jmsg) > 1:
664 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
665 revision = jmsg[0]['current_revision']
666 path = 'changes/%s/revisions/%s/review'
667 return ReadHttpJsonResponse(CreateHttpConn(host, path))
668
669
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700670def GetChangeComments(host, change):
671 """Get the line- and file-level comments on a change."""
672 path = 'changes/%s/comments' % change
673 return ReadHttpJsonResponse(CreateHttpConn(host, path))
674
675
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000676def GetChangeRobotComments(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000677 """Gets the line- and file-level robot comments on a change."""
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000678 path = 'changes/%s/robotcomments' % change
679 return ReadHttpJsonResponse(CreateHttpConn(host, path))
680
681
szager@chromium.orgb4696232013-10-16 19:45:35 +0000682def AbandonChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000683 """Abandons a Gerrit change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000684 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000685 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000686 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700687 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000688
689
690def RestoreChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000691 """Restores a previously abandoned change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000692 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000693 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000694 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700695 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000696
697
698def SubmitChange(host, change, wait_for_merge=True):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000699 """Submits a Gerrit change via Gerrit."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000700 path = 'changes/%s/submit' % change
701 body = {'wait_for_merge': wait_for_merge}
702 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700703 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000704
705
dsansomee2d6fd92016-09-08 00:10:47 -0700706def HasPendingChangeEdit(host, change):
707 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
708 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700709 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700710 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700711 # 204 No Content means no pending change.
712 if e.http_status == 204:
713 return False
714 raise
715 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700716
717
718def DeletePendingChangeEdit(host, change):
719 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000720 # On success, Gerrit returns status 204; if the edit was already deleted it
Aaron Gable19ee16c2017-04-18 11:56:35 -0700721 # returns 404. Anything else is an error.
722 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700723
724
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100725def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000726 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700727 assert notify in ('ALL', 'NONE')
728 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700729 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700730 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000731 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700732 ReadHttpResponse(conn, accept_statuses=[200, 204])
733 except GerritError as e:
734 raise GerritError(
735 e.http_status,
736 'Received unexpected http status while editing message '
737 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000738
739
szager@chromium.orgb4696232013-10-16 19:45:35 +0000740def GetReviewers(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000741 """Gets information about all reviewers attached to a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000742 path = 'changes/%s/reviewers' % change
743 return ReadHttpJsonResponse(CreateHttpConn(host, path))
744
745
746def GetReview(host, change, revision):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000747 """Gets review information about a specific revision of a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000748 path = 'changes/%s/revisions/%s/review' % (change, revision)
749 return ReadHttpJsonResponse(CreateHttpConn(host, path))
750
751
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700752def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
753 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000754 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700755 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800756 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200757 if not change:
758 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700759 reviewers = frozenset(reviewers or [])
760 ccs = frozenset(ccs or [])
761 path = 'changes/%s/revisions/current/review' % change
762
763 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800764 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700765 'reviewers': [],
766 'notify': 'ALL' if notify else 'NONE',
767 }
768 for r in sorted(reviewers | ccs):
769 state = 'REVIEWER' if r in reviewers else 'CC'
770 body['reviewers'].append({
771 'reviewer': r,
772 'state': state,
773 'notify': 'NONE', # We handled `notify` argument above.
Raul Tambre80ee78e2019-05-06 22:41:05 +0000774 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700775
776 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
777 # Gerrit will return 400 if one or more of the requested reviewers are
778 # unprocessable. We read the response object to see which were rejected,
779 # warn about them, and retry with the remainder.
780 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
781
782 errored = set()
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000783 for result in resp.get('reviewers', {}).values():
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700784 r = result.get('input')
785 state = 'REVIEWER' if r in reviewers else 'CC'
786 if result.get('error'):
787 errored.add(r)
788 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
789 if errored:
790 # Try again, adding only those that didn't fail, and only accepting 200.
791 AddReviewers(host, change, reviewers=(reviewers-errored),
792 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000793
794
795def RemoveReviewers(host, change, remove=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000796 """Removes reviewers from a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000797 if not remove:
798 return
799 if isinstance(remove, basestring):
800 remove = (remove,)
801 for r in remove:
802 path = 'changes/%s/reviewers/%s' % (change, r)
803 conn = CreateHttpConn(host, path, reqtype='DELETE')
804 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700805 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000806 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000807 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700808 e.http_status,
809 'Received unexpected http status while deleting reviewer "%s" '
810 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000811
812
Aaron Gable636b13f2017-07-14 10:42:48 -0700813def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000814 """Sets labels and/or adds a message to a code review."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000815 if not msg and not labels:
816 return
817 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800818 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000819 if msg:
820 body['message'] = msg
821 if labels:
822 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700823 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700824 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700825 if ready:
826 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000827 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
828 response = ReadHttpJsonResponse(conn)
829 if labels:
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000830 for key, val in labels.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000831 if ('labels' not in response or key not in response['labels'] or
832 int(response['labels'][key] != int(val))):
833 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
834 key, change))
835
836
837def ResetReviewLabels(host, change, label, value='0', message=None,
838 notify=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000839 """Resets the value of a given label for all reviewers on a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000840 # This is tricky, because we want to work on the "current revision", but
841 # there's always the risk that "current revision" will change in between
842 # API calls. So, we check "current revision" at the beginning and end; if
843 # it has changed, raise an exception.
844 jmsg = GetChangeCurrentRevision(host, change)
845 if not jmsg:
846 raise GerritError(
847 200, 'Could not get review information for change "%s"' % change)
848 value = str(value)
849 revision = jmsg[0]['current_revision']
850 path = 'changes/%s/revisions/%s/review' % (change, revision)
851 message = message or (
852 '%s label set to %s programmatically.' % (label, value))
853 jmsg = GetReview(host, change, revision)
854 if not jmsg:
855 raise GerritError(200, 'Could not get review information for revison %s '
856 'of change %s' % (revision, change))
857 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
858 if str(review.get('value', value)) != value:
859 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800860 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000861 'message': message,
862 'labels': {label: value},
863 'on_behalf_of': review['_account_id'],
864 }
865 if notify:
866 body['notify'] = notify
867 conn = CreateHttpConn(
868 host, path, reqtype='POST', body=body)
869 response = ReadHttpJsonResponse(conn)
870 if str(response['labels'][label]) != value:
871 username = review.get('email', jmsg.get('name', ''))
872 raise GerritError(200, 'Unable to set %s label for user "%s"'
873 ' on change %s.' % (label, username, change))
874 jmsg = GetChangeCurrentRevision(host, change)
875 if not jmsg:
876 raise GerritError(
877 200, 'Could not get review information for change "%s"' % change)
878 elif jmsg[0]['current_revision'] != revision:
879 raise GerritError(200, 'While resetting labels on change "%s", '
880 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800881
882
dimu833c94c2017-01-18 17:36:15 -0800883def CreateGerritBranch(host, project, branch, commit):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000884 """Creates a new branch from given project and commit
885
dimu833c94c2017-01-18 17:36:15 -0800886 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
887
888 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000889 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -0800890 """
891 path = 'projects/%s/branches/%s' % (project, branch)
892 body = {'revision': commit}
893 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700894 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800895 if response:
896 return response
897 raise GerritError(200, 'Unable to create gerrit branch')
898
899
900def GetGerritBranch(host, project, branch):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000901 """Gets a branch from given project and commit.
902
903 See:
dimu833c94c2017-01-18 17:36:15 -0800904 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
905
906 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000907 A JSON object with 'revision' key.
dimu833c94c2017-01-18 17:36:15 -0800908 """
909 path = 'projects/%s/branches/%s' % (project, branch)
910 conn = CreateHttpConn(host, path, reqtype='GET')
911 response = ReadHttpJsonResponse(conn)
912 if response:
913 return response
914 raise GerritError(200, 'Unable to get gerrit branch')
915
916
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100917def GetAccountDetails(host, account_id='self'):
918 """Returns details of the account.
919
920 If account_id is not given, uses magic value 'self' which corresponds to
921 whichever account user is authenticating as.
922
923 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000924 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000925
926 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100927 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100928 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000929 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
930
931
932def ValidAccounts(host, accounts, max_threads=10):
933 """Returns a mapping from valid account to its details.
934
935 Invalid accounts, either not existing or without unique match,
936 are not present as returned dictionary keys.
937 """
938 assert not isinstance(accounts, basestring), type(accounts)
939 accounts = list(set(accounts))
940 if not accounts:
941 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000942
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000943 def get_one(account):
944 try:
945 return account, GetAccountDetails(host, account)
946 except GerritError:
947 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000948
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000949 valid = {}
950 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
951 for account, details in pool.map(get_one, accounts):
952 if account and details:
953 valid[account] = details
954 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100955
956
Nick Carter8692b182017-11-06 16:30:38 -0800957def PercentEncodeForGitRef(original):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000958 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -0800959
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000960 The encoding used is based on but stricter than URL encoding (Section 2.1 of
961 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
962 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -0800963
964 For more information, see the Gerrit docs here:
965
966 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
967 """
968 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
969 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
970
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000971 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
Nick Carter8692b182017-11-06 16:30:38 -0800972 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
973 return encoded.replace(' ', '_')
974
975
Dan Jacques8d11e482016-11-15 14:25:56 -0800976@contextlib.contextmanager
977def tempdir():
978 tdir = None
979 try:
980 tdir = tempfile.mkdtemp(suffix='gerrit_util')
981 yield tdir
982 finally:
983 if tdir:
984 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000985
986
987def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +0000988 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000989 this module API.
990
991 Such format is allows for more efficient Gerrit routing of HTTP requests,
992 comparing to specifying just change_number.
993 """
994 assert int(change_number)
Edward Lemur5bfa3ae2019-10-25 22:18:40 +0000995 return '%s~%s' % (urllib.parse.quote(project, ''), change_number)