blob: 877c1144deefac59b0d90716dcc9f38229f799d6 [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 Lemurd2a5a4c2019-10-23 22:55:26 +000015import cookielib
16import httplib # Still used for its constants.
Edward Lemur202c5592019-10-21 22:44:52 +000017import httplib2
szager@chromium.orgb4696232013-10-16 19:45:35 +000018import json
19import logging
20import netrc
21import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000022import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000023import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000024import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000025import stat
26import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080027import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000028import time
29import urllib
Edward Lemurd2a5a4c2019-10-23 22:55:26 +000030import urlparse
31from cStringIO import StringIO
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000032from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000033
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070034import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080035import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000036import metrics
37import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080038import subprocess2
szager@chromium.orgf202a252014-05-27 18:55:52 +000039
szager@chromium.orgb4696232013-10-16 19:45:35 +000040LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000041# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
42# total tries, the sleep time between the first and last tries will be 94.5 sec.
Edward Lemurb1ae4812019-10-23 04:52:47 +000043TRY_LIMIT = 3
szager@chromium.orgb4696232013-10-16 19:45:35 +000044
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000045
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +000046# Controls the transport protocol used to communicate with Gerrit.
szager@chromium.orgb4696232013-10-16 19:45:35 +000047# This is parameterized primarily to enable GerritTestCase.
48GERRIT_PROTOCOL = 'https'
49
50
51class GerritError(Exception):
52 """Exception class for errors commuicating with the gerrit-on-borg service."""
53 def __init__(self, http_status, *args, **kwargs):
54 super(GerritError, self).__init__(*args, **kwargs)
55 self.http_status = http_status
56 self.message = '(%d) %s' % (self.http_status, self.message)
57
58
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000059class GerritAuthenticationError(GerritError):
60 """Exception class for authentication errors during Gerrit communication."""
61
62
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020063def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000064 """Encodes query parameters in the key:val[+key:val...] format specified here:
65
66 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
67 """
68 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020069 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000070 return '+'.join(q)
71
72
Aaron Gabled2db5a22017-03-24 14:14:15 -070073def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000074 if protocol is None:
75 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010076 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070077 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000078 else:
79 raise RuntimeError(
80 "Don't know how to work with protocol '%s'" % protocol)
81
82
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000083class Authenticator(object):
84 """Base authenticator class for authenticator implementations to subclass."""
85
86 def get_auth_header(self, host):
87 raise NotImplementedError()
88
89 @staticmethod
90 def get():
91 """Returns: (Authenticator) The identified Authenticator to use.
92
93 Probes the local system and its environment and identifies the
94 Authenticator instance to use.
95 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070096 # LUCI Context takes priority since it's normally present only on bots,
97 # which then must use it.
98 if LuciContextAuthenticator.is_luci():
99 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000100 if GceAuthenticator.is_gce():
101 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000102 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000103
104
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000105class CookiesAuthenticator(Authenticator):
106 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
107
108 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000109 """
110
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000111 _EMPTY = object()
112
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000113 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000114 # Credentials will be loaded lazily on first use. This ensures Authenticator
115 # get() can always construct an authenticator, even if something is broken.
116 # This allows 'creds-check' to proceed to actually checking creds later,
117 # rigorously (instead of blowing up with a cryptic error if they are wrong).
118 self._netrc = self._EMPTY
119 self._gitcookies = self._EMPTY
120
121 @property
122 def netrc(self):
123 if self._netrc is self._EMPTY:
124 self._netrc = self._get_netrc()
125 return self._netrc
126
127 @property
128 def gitcookies(self):
129 if self._gitcookies is self._EMPTY:
130 self._gitcookies = self._get_gitcookies()
131 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000132
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000133 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200134 def get_new_password_url(cls, host):
135 assert not host.startswith('http')
136 # Assume *.googlesource.com pattern.
137 parts = host.split('.')
138 if not parts[0].endswith('-review'):
139 parts[0] += '-review'
140 return 'https://%s/new-password' % ('.'.join(parts))
141
142 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000143 def get_new_password_message(cls, host):
William Hessee9e89e32019-03-03 19:02:32 +0000144 if host is None:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000145 return ('Git host for Gerrit upload is unknown. Check your remote '
William Hessee9e89e32019-03-03 19:02:32 +0000146 'and the branch your branch is tracking. This tool assumes '
147 'that you are using a git server at *.googlesource.com.')
Edward Lemur67fccdf2019-10-22 22:17:10 +0000148 url = cls.get_new_password_url(host)
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100149 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000150
151 @classmethod
152 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000153 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000154 return os.path.expanduser(os.path.join('~', path))
155
156 @classmethod
157 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800158 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000159 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000160 if not os.path.exists(path):
161 return netrc.netrc(os.devnull)
162
163 st = os.stat(path)
164 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000165 print(
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000166 'WARNING: netrc file %s cannot be used because its file '
167 'permissions are insecure. netrc file permissions should be '
Raul Tambre80ee78e2019-05-06 22:41:05 +0000168 '600.' % path, file=sys.stderr)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000169 with open(path) as fd:
170 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800171
172 # Load the '.netrc' file. We strip comments from it because processing them
173 # can trigger a bug in Windows. See crbug.com/664664.
174 content = '\n'.join(l for l in content.splitlines()
175 if l.strip() and not l.strip().startswith('#'))
176 with tempdir() as tdir:
177 netrc_path = os.path.join(tdir, 'netrc')
178 with open(netrc_path, 'w') as fd:
179 fd.write(content)
180 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
181 return cls._get_netrc_from_path(netrc_path)
182
183 @classmethod
184 def _get_netrc_from_path(cls, path):
185 try:
186 return netrc.netrc(path)
187 except IOError:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000188 print('WARNING: Could not read netrc file %s' % path, file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800189 return netrc.netrc(os.devnull)
190 except netrc.NetrcParseError as e:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000191 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
192 (path, e), file=sys.stderr)
Dan Jacques8d11e482016-11-15 14:25:56 -0800193 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000194
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000195 @classmethod
196 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500197 if os.getenv('GIT_COOKIES_PATH'):
198 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800199 try:
200 return subprocess2.check_output(
201 ['git', 'config', '--path', 'http.cookiefile']).strip()
202 except subprocess2.CalledProcessError:
203 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000204
205 @classmethod
206 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000207 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000208 path = cls.get_gitcookies_path()
209 if not os.path.exists(path):
210 return gitcookies
211
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000212 try:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000213 f = gclient_utils.FileRead(path, 'rb').splitlines()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000214 except IOError:
215 return gitcookies
216
Edward Lemur67fccdf2019-10-22 22:17:10 +0000217 for line in f:
218 try:
219 fields = line.strip().split('\t')
220 if line.strip().startswith('#') or len(fields) != 7:
221 continue
222 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
223 if xpath == '/' and key == 'o':
224 if value.startswith('git-'):
225 login, secret_token = value.split('=', 1)
226 gitcookies[domain] = (login, secret_token)
227 else:
228 gitcookies[domain] = ('', value)
229 except (IndexError, ValueError, TypeError) as exc:
230 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000231 return gitcookies
232
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100233 def _get_auth_for_host(self, host):
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000234 for domain, creds in self.gitcookies.items():
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000235 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100236 return (creds[0], None, creds[1])
237 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000238
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100239 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700240 a = self._get_auth_for_host(host)
241 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000242 if a[0]:
Edward Lemur67fccdf2019-10-22 22:17:10 +0000243 secret = base64.b64encode(('%s:%s' % (a[0], a[2])).encode('utf-8'))
244 return 'Basic %s' % secret.decode('utf-8')
Eric Boren2fb63102018-10-05 13:05:03 +0000245 else:
246 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000247 return None
248
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100249 def get_auth_email(self, host):
250 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700251 a = self._get_auth_for_host(host)
252 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100253 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700254 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100255 # login typically looks like 'git-xxx.example.com'
256 if not login.startswith('git-') or '.' not in login:
257 return None
258 username, domain = login[len('git-'):].split('.', 1)
259 return '%s@%s' % (username, domain)
260
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100261
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000262# Backwards compatibility just in case somebody imports this outside of
263# depot_tools.
264NetrcAuthenticator = CookiesAuthenticator
265
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000266
267class GceAuthenticator(Authenticator):
268 """Authenticator implementation that uses GCE metadata service for token.
269 """
270
271 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700272 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
273 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000274 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
275
276 _cache_is_gce = None
277 _token_cache = None
278 _token_expiration = None
279
280 @classmethod
281 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500282 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
283 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000284 if cls._cache_is_gce is None:
285 cls._cache_is_gce = cls._test_is_gce()
286 return cls._cache_is_gce
287
288 @classmethod
289 def _test_is_gce(cls):
290 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
291 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100292 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100293 except (socket.error, httplib2.ServerNotFoundError,
294 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000295 # Could not resolve URL.
296 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100297 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000298
299 @staticmethod
300 def _get(url, **kwargs):
301 next_delay_sec = 1
302 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700304 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100305 resp, contents = c.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)
307 if resp.status < httplib.INTERNAL_SERVER_ERROR:
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)
315 time.sleep(next_delay_sec)
316 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)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000326 if resp.status != httplib.OK:
327 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:
373 body = json.JSONEncoder().encode(body)
374 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)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700383 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000384 # HACK: httplib.Http has no such attribute; we store req_host here for later
385 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000386 conn.req_host = host
387 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100388 'uri': urlparse.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)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000409
Edward Lemur5a9ff432018-10-30 19:00:22 +0000410 response_time = time.time() - before_response
411 metrics.collector.add_repeated(
412 'http_requests',
413 metrics_utils.extract_http_metrics(
414 conn.req_params['uri'], conn.req_params['method'], response.status,
415 response_time))
416
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000417 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100418 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000419 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
420 www_authenticate):
421 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
422 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700423 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000424 'has credentials for %s' % host)
425 raise GerritAuthenticationError(response.status, reason)
426
szager@chromium.orgb4696232013-10-16 19:45:35 +0000427 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000428 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700429 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000430 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700431 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100432 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100433 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700434 # If 404 was in accept_statuses, then it's expected that the file might
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000435 # not exist, so don't return the gitiles error page because that's not
436 # the "content" that was actually requested.
Michael Mossb40a4512017-10-10 11:07:17 -0700437 if response.status == 404:
438 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000439 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000440 # A status >=500 is assumed to be a possible transient error; retry.
441 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
442 LOGGER.warn('A transient error occurred while querying %s:\n'
443 '%s %s %s\n'
444 '%s %d %s',
445 conn.req_host, conn.req_params['method'],
446 conn.req_params['uri'],
447 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000448
szager@chromium.orgb4696232013-10-16 19:45:35 +0000449 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800450 LOGGER.info('Will retry in %d seconds (%d more times)...',
451 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000452 time.sleep(sleep_time)
453 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000454 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700455 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200456 if response.status in (401, 403):
457 print('Your Gerrit credentials might be misconfigured. Try: \n'
458 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100459 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000460 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100461 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000462
463
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700464def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000465 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700466 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000467 # The first line of the response should always be: )]}'
468 s = fh.readline()
469 if s and s.rstrip() != ")]}'":
470 raise GerritError(200, 'Unexpected json output: %s' % s)
471 s = fh.read()
472 if not s:
473 return None
474 return json.loads(s)
475
476
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200477def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100478 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000479 """
480 Queries a gerrit-on-borg server for changes matching query terms.
481
482 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200483 params: A list of key:value pairs for search parameters, as documented
484 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
485 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000486 first_param: A change identifier
487 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100488 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000489 o_params: A list of additional output specifiers, as documented here:
490 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000491
szager@chromium.orgb4696232013-10-16 19:45:35 +0000492 Returns:
493 A list of json-decoded query results.
494 """
495 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200496 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000497 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200498 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 if start:
500 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000501 if limit:
502 path = '%s&n=%d' % (path, limit)
503 if o_params:
504 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700505 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000506
507
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200508def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100509 o_params=None, start=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000510 """Queries a gerrit-on-borg server for all the changes matching the query
511 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000512
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100513 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000514 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100515
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000516 A single query to gerrit-on-borg is limited on the number of results by the
517 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100518 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000519
520 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200521 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000522 limit: Maximum number of requested changes per query.
523 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100524 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000525
526 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100527 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000528 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100529 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000530
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 def at_most_once(cls):
532 for cl in cls:
533 if cl['_number'] not in already_returned:
534 already_returned.add(cl['_number'])
535 yield cl
536
537 start = start or 0
538 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000539 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100540
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000541 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100542 # This will fetch changes[start..start+limit] sorted by most recently
543 # updated. Since the rank of any change in this list can be changed any time
544 # (say user posting comment), subsequent calls may overalp like this:
545 # > initial order ABCDEFGH
546 # query[0..3] => ABC
547 # > E get's updated. New order: EABCDFGH
548 # query[3..6] => CDF # C is a dup
549 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200550 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100551 cur_start)
552 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000553 yield cl
554
555 more_changes = [cl for cl in page if '_more_changes' in cl]
556 if len(more_changes) > 1:
557 raise GerritError(
558 200,
559 'Received %d changes with a _more_changes attribute set but should '
560 'receive at most one.' % len(more_changes))
561 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100562 cur_start += len(page)
563
564 # If we paged through, query again the first page which in most circumstances
565 # will fetch all changes that were modified while this function was run.
566 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200567 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100568 for cl in at_most_once(page):
569 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000570
571
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200572def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100573 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000574 """Initiate a query composed of multiple sets of query parameters."""
575 if not change_list:
576 raise RuntimeError(
577 "MultiQueryChanges requires a list of change numbers/id's")
578 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200579 if params:
580 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000581 if limit:
582 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100583 if start:
584 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000585 if o_params:
586 q.extend(['o=%s' % p for p in o_params])
587 path = 'changes/?%s' % '&'.join(q)
588 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700589 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000590 except GerritError as e:
591 msg = '%s:\n%s' % (e.message, path)
592 raise GerritError(e.http_status, msg)
593 return result
594
595
596def GetGerritFetchUrl(host):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000597 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000598 return '%s://%s/' % (GERRIT_PROTOCOL, host)
599
600
Edward Lemur687ca902018-12-05 02:30:30 +0000601def GetCodeReviewTbrScore(host, project):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000602 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000603 """
604 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
605 project = ReadHttpJsonResponse(conn)
606 if ('labels' not in project
607 or 'Code-Review' not in project['labels']
608 or 'values' not in project['labels']['Code-Review']):
609 return 1
610 return max([int(x) for x in project['labels']['Code-Review']['values']])
611
612
szager@chromium.orgb4696232013-10-16 19:45:35 +0000613def GetChangePageUrl(host, change_number):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000614 """Given a Gerrit host name and change number, returns change page URL."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000615 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
616
617
618def GetChangeUrl(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000619 """Given a Gerrit host name and change ID, returns a URL for the change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000620 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
621
622
623def GetChange(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000624 """Queries a Gerrit server for information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000625 path = 'changes/%s' % change
626 return ReadHttpJsonResponse(CreateHttpConn(host, path))
627
628
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700629def GetChangeDetail(host, change, o_params=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000630 """Queries a Gerrit server for extended information about a single change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000631 path = 'changes/%s/detail' % change
632 if o_params:
633 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700634 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000635
636
agable32978d92016-11-01 12:55:02 -0700637def GetChangeCommit(host, change, revision='current'):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000638 """Query a Gerrit server for a revision associated with a change."""
agable32978d92016-11-01 12:55:02 -0700639 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
640 return ReadHttpJsonResponse(CreateHttpConn(host, path))
641
642
szager@chromium.orgb4696232013-10-16 19:45:35 +0000643def GetChangeCurrentRevision(host, change):
644 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200645 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000646
647
648def GetChangeRevisions(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000649 """Gets information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200650 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000651
652
653def GetChangeReview(host, change, revision=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000654 """Gets the current review information for a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000655 if not revision:
656 jmsg = GetChangeRevisions(host, change)
657 if not jmsg:
658 return None
659 elif len(jmsg) > 1:
660 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
661 revision = jmsg[0]['current_revision']
662 path = 'changes/%s/revisions/%s/review'
663 return ReadHttpJsonResponse(CreateHttpConn(host, path))
664
665
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700666def GetChangeComments(host, change):
667 """Get the line- and file-level comments on a change."""
668 path = 'changes/%s/comments' % change
669 return ReadHttpJsonResponse(CreateHttpConn(host, path))
670
671
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000672def GetChangeRobotComments(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000673 """Gets the line- and file-level robot comments on a change."""
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000674 path = 'changes/%s/robotcomments' % change
675 return ReadHttpJsonResponse(CreateHttpConn(host, path))
676
677
szager@chromium.orgb4696232013-10-16 19:45:35 +0000678def AbandonChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000679 """Abandons a Gerrit change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000680 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000681 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000682 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700683 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000684
685
686def RestoreChange(host, change, msg=''):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000687 """Restores a previously abandoned change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000688 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000689 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000690 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700691 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000692
693
694def SubmitChange(host, change, wait_for_merge=True):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000695 """Submits a Gerrit change via Gerrit."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000696 path = 'changes/%s/submit' % change
697 body = {'wait_for_merge': wait_for_merge}
698 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700699 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000700
701
dsansomee2d6fd92016-09-08 00:10:47 -0700702def HasPendingChangeEdit(host, change):
703 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
704 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700705 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700706 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700707 # 204 No Content means no pending change.
708 if e.http_status == 204:
709 return False
710 raise
711 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700712
713
714def DeletePendingChangeEdit(host, change):
715 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000716 # On success, Gerrit returns status 204; if the edit was already deleted it
Aaron Gable19ee16c2017-04-18 11:56:35 -0700717 # returns 404. Anything else is an error.
718 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700719
720
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100721def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000722 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700723 assert notify in ('ALL', 'NONE')
724 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700725 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700726 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000727 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700728 ReadHttpResponse(conn, accept_statuses=[200, 204])
729 except GerritError as e:
730 raise GerritError(
731 e.http_status,
732 'Received unexpected http status while editing message '
733 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000734
735
szager@chromium.orgb4696232013-10-16 19:45:35 +0000736def GetReviewers(host, change):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000737 """Gets information about all reviewers attached to a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000738 path = 'changes/%s/reviewers' % change
739 return ReadHttpJsonResponse(CreateHttpConn(host, path))
740
741
742def GetReview(host, change, revision):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000743 """Gets review information about a specific revision of a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000744 path = 'changes/%s/revisions/%s/review' % (change, revision)
745 return ReadHttpJsonResponse(CreateHttpConn(host, path))
746
747
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700748def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
749 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000750 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700751 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800752 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200753 if not change:
754 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700755 reviewers = frozenset(reviewers or [])
756 ccs = frozenset(ccs or [])
757 path = 'changes/%s/revisions/current/review' % change
758
759 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800760 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700761 'reviewers': [],
762 'notify': 'ALL' if notify else 'NONE',
763 }
764 for r in sorted(reviewers | ccs):
765 state = 'REVIEWER' if r in reviewers else 'CC'
766 body['reviewers'].append({
767 'reviewer': r,
768 'state': state,
769 'notify': 'NONE', # We handled `notify` argument above.
Raul Tambre80ee78e2019-05-06 22:41:05 +0000770 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700771
772 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
773 # Gerrit will return 400 if one or more of the requested reviewers are
774 # unprocessable. We read the response object to see which were rejected,
775 # warn about them, and retry with the remainder.
776 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
777
778 errored = set()
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000779 for result in resp.get('reviewers', {}).values():
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700780 r = result.get('input')
781 state = 'REVIEWER' if r in reviewers else 'CC'
782 if result.get('error'):
783 errored.add(r)
784 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
785 if errored:
786 # Try again, adding only those that didn't fail, and only accepting 200.
787 AddReviewers(host, change, reviewers=(reviewers-errored),
788 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000789
790
791def RemoveReviewers(host, change, remove=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000792 """Removes reviewers from a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000793 if not remove:
794 return
795 if isinstance(remove, basestring):
796 remove = (remove,)
797 for r in remove:
798 path = 'changes/%s/reviewers/%s' % (change, r)
799 conn = CreateHttpConn(host, path, reqtype='DELETE')
800 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700801 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000802 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000803 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700804 e.http_status,
805 'Received unexpected http status while deleting reviewer "%s" '
806 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000807
808
Aaron Gable636b13f2017-07-14 10:42:48 -0700809def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000810 """Sets labels and/or adds a message to a code review."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000811 if not msg and not labels:
812 return
813 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800814 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000815 if msg:
816 body['message'] = msg
817 if labels:
818 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700819 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700820 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700821 if ready:
822 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000823 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
824 response = ReadHttpJsonResponse(conn)
825 if labels:
Marc-Antoine Ruel8e57b4b2019-10-11 01:01:36 +0000826 for key, val in labels.items():
szager@chromium.orgb4696232013-10-16 19:45:35 +0000827 if ('labels' not in response or key not in response['labels'] or
828 int(response['labels'][key] != int(val))):
829 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
830 key, change))
831
832
833def ResetReviewLabels(host, change, label, value='0', message=None,
834 notify=None):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000835 """Resets the value of a given label for all reviewers on a change."""
szager@chromium.orgb4696232013-10-16 19:45:35 +0000836 # This is tricky, because we want to work on the "current revision", but
837 # there's always the risk that "current revision" will change in between
838 # API calls. So, we check "current revision" at the beginning and end; if
839 # it has changed, raise an exception.
840 jmsg = GetChangeCurrentRevision(host, change)
841 if not jmsg:
842 raise GerritError(
843 200, 'Could not get review information for change "%s"' % change)
844 value = str(value)
845 revision = jmsg[0]['current_revision']
846 path = 'changes/%s/revisions/%s/review' % (change, revision)
847 message = message or (
848 '%s label set to %s programmatically.' % (label, value))
849 jmsg = GetReview(host, change, revision)
850 if not jmsg:
851 raise GerritError(200, 'Could not get review information for revison %s '
852 'of change %s' % (revision, change))
853 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
854 if str(review.get('value', value)) != value:
855 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800856 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000857 'message': message,
858 'labels': {label: value},
859 'on_behalf_of': review['_account_id'],
860 }
861 if notify:
862 body['notify'] = notify
863 conn = CreateHttpConn(
864 host, path, reqtype='POST', body=body)
865 response = ReadHttpJsonResponse(conn)
866 if str(response['labels'][label]) != value:
867 username = review.get('email', jmsg.get('name', ''))
868 raise GerritError(200, 'Unable to set %s label for user "%s"'
869 ' on change %s.' % (label, username, change))
870 jmsg = GetChangeCurrentRevision(host, change)
871 if not jmsg:
872 raise GerritError(
873 200, 'Could not get review information for change "%s"' % change)
874 elif jmsg[0]['current_revision'] != revision:
875 raise GerritError(200, 'While resetting labels on change "%s", '
876 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800877
878
dimu833c94c2017-01-18 17:36:15 -0800879def CreateGerritBranch(host, project, branch, commit):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000880 """Creates a new branch from given project and commit
881
dimu833c94c2017-01-18 17:36:15 -0800882 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
883
884 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000885 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -0800886 """
887 path = 'projects/%s/branches/%s' % (project, branch)
888 body = {'revision': commit}
889 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700890 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800891 if response:
892 return response
893 raise GerritError(200, 'Unable to create gerrit branch')
894
895
896def GetGerritBranch(host, project, branch):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000897 """Gets a branch from given project and commit.
898
899 See:
dimu833c94c2017-01-18 17:36:15 -0800900 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
901
902 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000903 A JSON object with 'revision' key.
dimu833c94c2017-01-18 17:36:15 -0800904 """
905 path = 'projects/%s/branches/%s' % (project, branch)
906 conn = CreateHttpConn(host, path, reqtype='GET')
907 response = ReadHttpJsonResponse(conn)
908 if response:
909 return response
910 raise GerritError(200, 'Unable to get gerrit branch')
911
912
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100913def GetAccountDetails(host, account_id='self'):
914 """Returns details of the account.
915
916 If account_id is not given, uses magic value 'self' which corresponds to
917 whichever account user is authenticating as.
918
919 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000920 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000921
922 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100923 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100924 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000925 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
926
927
928def ValidAccounts(host, accounts, max_threads=10):
929 """Returns a mapping from valid account to its details.
930
931 Invalid accounts, either not existing or without unique match,
932 are not present as returned dictionary keys.
933 """
934 assert not isinstance(accounts, basestring), type(accounts)
935 accounts = list(set(accounts))
936 if not accounts:
937 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000938
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000939 def get_one(account):
940 try:
941 return account, GetAccountDetails(host, account)
942 except GerritError:
943 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000944
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000945 valid = {}
946 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
947 for account, details in pool.map(get_one, accounts):
948 if account and details:
949 valid[account] = details
950 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100951
952
Nick Carter8692b182017-11-06 16:30:38 -0800953def PercentEncodeForGitRef(original):
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000954 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -0800955
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000956 The encoding used is based on but stricter than URL encoding (Section 2.1 of
957 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
958 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -0800959
960 For more information, see the Gerrit docs here:
961
962 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
963 """
964 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
965 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
966
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000967 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
Nick Carter8692b182017-11-06 16:30:38 -0800968 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
969 return encoded.replace(' ', '_')
970
971
Dan Jacques8d11e482016-11-15 14:25:56 -0800972@contextlib.contextmanager
973def tempdir():
974 tdir = None
975 try:
976 tdir = tempfile.mkdtemp(suffix='gerrit_util')
977 yield tdir
978 finally:
979 if tdir:
980 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000981
982
983def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +0000984 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000985 this module API.
986
987 Such format is allows for more efficient Gerrit routing of HTTP requests,
988 comparing to specifying just change_number.
989 """
990 assert int(change_number)
991 return '%s~%s' % (urllib.quote(project, safe=''), change_number)