blob: a01732207cacbc04b8bf8c0fba9d86a28abc0f65 [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.
szager@chromium.orgb4696232013-10-16 19:45:35 +00004"""
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00005Utilities for requesting information for a Gerrit server via HTTPS.
szager@chromium.orgb4696232013-10-16 19:45:35 +00006
7https://gerrit-review.googlesource.com/Documentation/rest-api.html
8"""
9
10import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080011import contextlib
Edward Lemur202c5592019-10-21 22:44:52 +000012import httplib2
szager@chromium.orgb4696232013-10-16 19:45:35 +000013import json
14import logging
15import netrc
16import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000017import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000018import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000019import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000020import stat
21import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080022import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000023import time
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000024from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000025
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070026import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080027import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000028import metrics
29import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080030import subprocess2
szager@chromium.orgf202a252014-05-27 18:55:52 +000031
Edward Lemur4ba192e2019-10-28 20:19:37 +000032from six.moves import urllib
33
Gavin Makcc976552023-08-28 17:01:52 +000034import http.cookiejar
35from io import StringIO
Edward Lemur4ba192e2019-10-28 20:19:37 +000036
Mike Frysinger124bb8e2023-09-06 05:48:55 +000037# TODO: Should fix these warnings.
38# pylint: disable=line-too-long
39
szager@chromium.orgb4696232013-10-16 19:45:35 +000040LOGGER = logging.getLogger()
Gavin Makfc75af32023-06-20 20:06:27 +000041# With a starting sleep time of 12.0 seconds, x <= [1.8-2.2]x backoff, and six
42# total tries, the sleep time between the first and last tries will be ~6 min
43# (excluding time for each try).
44TRY_LIMIT = 6
45SLEEP_TIME = 12.0
George Engelbrecht888c0fe2020-04-17 15:00:20 +000046MAX_BACKOFF = 2.2
47MIN_BACKOFF = 1.8
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000048
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +000049# Controls the transport protocol used to communicate with Gerrit.
szager@chromium.orgb4696232013-10-16 19:45:35 +000050# This is parameterized primarily to enable GerritTestCase.
51GERRIT_PROTOCOL = 'https'
52
53
Edward Lemur4ba192e2019-10-28 20:19:37 +000054def time_sleep(seconds):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000055 # Use this so that it can be mocked in tests without interfering with python
56 # system machinery.
57 return time.sleep(seconds)
Edward Lemur4ba192e2019-10-28 20:19:37 +000058
59
Edward Lemura3b6fd02020-03-02 22:16:15 +000060def time_time():
Mike Frysinger124bb8e2023-09-06 05:48:55 +000061 # Use this so that it can be mocked in tests without interfering with python
62 # system machinery.
63 return time.time()
Edward Lemura3b6fd02020-03-02 22:16:15 +000064
65
Ben Pastene9519fc12023-04-12 22:15:43 +000066def log_retry_and_sleep(seconds, attempt):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000067 LOGGER.info('Will retry in %d seconds (%d more times)...', seconds,
68 TRY_LIMIT - attempt - 1)
69 time_sleep(seconds)
70 return seconds * random.uniform(MIN_BACKOFF, MAX_BACKOFF)
Ben Pastene9519fc12023-04-12 22:15:43 +000071
72
szager@chromium.orgb4696232013-10-16 19:45:35 +000073class GerritError(Exception):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000074 """Exception class for errors commuicating with the gerrit-on-borg service."""
75 def __init__(self, http_status, message, *args, **kwargs):
76 super(GerritError, self).__init__(*args, **kwargs)
77 self.http_status = http_status
78 self.message = '(%d) %s' % (self.http_status, message)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000079
Mike Frysinger124bb8e2023-09-06 05:48:55 +000080 def __str__(self):
81 return self.message
Josip Sokcevicdf9a8022020-12-08 00:10:19 +000082
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000083
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020084def _QueryString(params, first_param=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000085 """Encodes query parameters in the key:val[+key:val...] format specified here:
szager@chromium.orgb4696232013-10-16 19:45:35 +000086
87 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
88 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000089 q = [urllib.parse.quote(first_param)] if first_param else []
90 q.extend(['%s:%s' % (key, val.replace(" ", "+")) for key, val in params])
91 return '+'.join(q)
szager@chromium.orgb4696232013-10-16 19:45:35 +000092
93
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000094class Authenticator(object):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000095 """Base authenticator class for authenticator implementations to subclass."""
96 def get_auth_header(self, host):
97 raise NotImplementedError()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000098
Mike Frysinger124bb8e2023-09-06 05:48:55 +000099 @staticmethod
100 def get():
101 """Returns: (Authenticator) The identified Authenticator to use.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000102
103 Probes the local system and its environment and identifies the
104 Authenticator instance to use.
105 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000106 # LUCI Context takes priority since it's normally present only on bots,
107 # which then must use it.
108 if LuciContextAuthenticator.is_luci():
109 return LuciContextAuthenticator()
110 # TODO(crbug.com/1059384): Automatically detect when running on
111 # cloudtop, and use CookiesAuthenticator instead.
112 if GceAuthenticator.is_gce():
113 return GceAuthenticator()
114 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000115
116
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000117class CookiesAuthenticator(Authenticator):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000118 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000119
120 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000121 """
122
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000123 _EMPTY = object()
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000124
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000125 def __init__(self):
126 # Credentials will be loaded lazily on first use. This ensures
127 # Authenticator get() can always construct an authenticator, even if
128 # something is broken. This allows 'creds-check' to proceed to actually
129 # checking creds later, rigorously (instead of blowing up with a cryptic
130 # error if they are wrong).
131 self._netrc = self._EMPTY
132 self._gitcookies = self._EMPTY
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000133
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000134 @property
135 def netrc(self):
136 if self._netrc is self._EMPTY:
137 self._netrc = self._get_netrc()
138 return self._netrc
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000139
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000140 @property
141 def gitcookies(self):
142 if self._gitcookies is self._EMPTY:
143 self._gitcookies = self._get_gitcookies()
144 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000145
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000146 @classmethod
147 def get_new_password_url(cls, host):
148 assert not host.startswith('http')
149 # Assume *.googlesource.com pattern.
150 parts = host.split('.')
Aravind Vasudevana02b4bf2023-02-03 17:52:03 +0000151
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000152 # remove -review suffix if present.
153 if parts[0].endswith('-review'):
154 parts[0] = parts[0][:-len('-review')]
Aravind Vasudevana02b4bf2023-02-03 17:52:03 +0000155
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000156 return 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200157
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000158 @classmethod
159 def get_new_password_message(cls, host):
160 if host is None:
161 return ('Git host for Gerrit upload is unknown. Check your remote '
162 'and the branch your branch is tracking. This tool assumes '
163 'that you are using a git server at *.googlesource.com.')
164 url = cls.get_new_password_url(host)
165 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000166
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000167 @classmethod
168 def get_netrc_path(cls):
169 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
170 return os.path.expanduser(os.path.join('~', path))
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000171
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000172 @classmethod
173 def _get_netrc(cls):
174 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
175 path = cls.get_netrc_path()
176 if not os.path.exists(path):
177 return netrc.netrc(os.devnull)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000178
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000179 st = os.stat(path)
180 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
181 print('WARNING: netrc file %s cannot be used because its file '
182 'permissions are insecure. netrc file permissions should be '
183 '600.' % path,
184 file=sys.stderr)
185 with open(path) as fd:
186 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800187
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000188 # Load the '.netrc' file. We strip comments from it because processing
189 # them can trigger a bug in Windows. See crbug.com/664664.
190 content = '\n'.join(l for l in content.splitlines()
191 if l.strip() and not l.strip().startswith('#'))
192 with tempdir() as tdir:
193 netrc_path = os.path.join(tdir, 'netrc')
194 with open(netrc_path, 'w') as fd:
195 fd.write(content)
196 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
197 return cls._get_netrc_from_path(netrc_path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800198
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000199 @classmethod
200 def _get_netrc_from_path(cls, path):
201 try:
202 return netrc.netrc(path)
203 except IOError:
204 print('WARNING: Could not read netrc file %s' % path,
205 file=sys.stderr)
206 return netrc.netrc(os.devnull)
207 except netrc.NetrcParseError as e:
208 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
209 (path, e),
210 file=sys.stderr)
211 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000212
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000213 @classmethod
214 def get_gitcookies_path(cls):
215 if os.getenv('GIT_COOKIES_PATH'):
216 return os.getenv('GIT_COOKIES_PATH')
217 try:
218 path = subprocess2.check_output(
219 ['git', 'config', '--path', 'http.cookiefile'])
220 return path.decode('utf-8', 'ignore').strip()
221 except subprocess2.CalledProcessError:
222 return os.path.expanduser(os.path.join('~', '.gitcookies'))
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000223
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000224 @classmethod
225 def _get_gitcookies(cls):
226 gitcookies = {}
227 path = cls.get_gitcookies_path()
228 if not os.path.exists(path):
229 return gitcookies
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000230
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000231 try:
232 f = gclient_utils.FileRead(path, 'rb').splitlines()
233 except IOError:
234 return gitcookies
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000235
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000236 for line in f:
237 try:
238 fields = line.strip().split('\t')
239 if line.strip().startswith('#') or len(fields) != 7:
240 continue
241 domain, xpath, key, value = fields[0], fields[2], fields[
242 5], fields[6]
243 if xpath == '/' and key == 'o':
244 if value.startswith('git-'):
245 login, secret_token = value.split('=', 1)
246 gitcookies[domain] = (login, secret_token)
247 else:
248 gitcookies[domain] = ('', value)
249 except (IndexError, ValueError, TypeError) as exc:
250 LOGGER.warning(exc)
251 return gitcookies
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000252
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000253 def _get_auth_for_host(self, host):
254 for domain, creds in self.gitcookies.items():
255 if http.cookiejar.domain_match(host, domain):
256 return (creds[0], None, creds[1])
257 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000258
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000259 def get_auth_header(self, host):
260 a = self._get_auth_for_host(host)
261 if a:
262 if a[0]:
263 secret = base64.b64encode(
264 ('%s:%s' % (a[0], a[2])).encode('utf-8'))
265 return 'Basic %s' % secret.decode('utf-8')
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +0000266
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000267 return 'Bearer %s' % a[2]
268 return None
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000269
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000270 def get_auth_email(self, host):
271 """Best effort parsing of email to be used for auth for the given host."""
272 a = self._get_auth_for_host(host)
273 if not a:
274 return None
275 login = a[0]
276 # login typically looks like 'git-xxx.example.com'
277 if not login.startswith('git-') or '.' not in login:
278 return None
279 username, domain = login[len('git-'):].split('.', 1)
280 return '%s@%s' % (username, domain)
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100281
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100282
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000283# Backwards compatibility just in case somebody imports this outside of
284# depot_tools.
285NetrcAuthenticator = CookiesAuthenticator
286
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000287
288class GceAuthenticator(Authenticator):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000289 """Authenticator implementation that uses GCE metadata service for token.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000290 """
291
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000292 _INFO_URL = 'http://metadata.google.internal'
293 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
294 'service-accounts/default/token' % _INFO_URL)
295 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000296
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000297 _cache_is_gce = None
298 _token_cache = None
299 _token_expiration = None
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000300
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000301 @classmethod
302 def is_gce(cls):
303 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
304 return False
305 if cls._cache_is_gce is None:
306 cls._cache_is_gce = cls._test_is_gce()
307 return cls._cache_is_gce
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000308
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000309 @classmethod
310 def _test_is_gce(cls):
311 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
312 resp, _ = cls._get(cls._INFO_URL)
313 if resp is None:
314 return False
315 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000316
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000317 @staticmethod
318 def _get(url, **kwargs):
319 next_delay_sec = 1.0
320 for i in range(TRY_LIMIT):
321 p = urllib.parse.urlparse(url)
322 if p.scheme not in ('http', 'https'):
323 raise RuntimeError("Don't know how to work with protocol '%s'" %
324 protocol)
325 try:
326 resp, contents = httplib2.Http().request(url, 'GET', **kwargs)
327 except (socket.error, httplib2.HttpLib2Error,
328 httplib2.socks.ProxyError) as e:
329 LOGGER.debug('GET [%s] raised %s', url, e)
330 return None, None
331 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i + 1, TRY_LIMIT,
332 resp.status)
333 if resp.status < 500:
334 return (resp, contents)
335
336 # Retry server error status codes.
337 LOGGER.warn('Encountered server error')
338 if TRY_LIMIT - i > 1:
339 next_delay_sec = log_retry_and_sleep(next_delay_sec, i)
Edward Lemura3b6fd02020-03-02 22:16:15 +0000340 return None, None
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000341
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000342 @classmethod
343 def _get_token_dict(cls):
344 # If cached token is valid for at least 25 seconds, return it.
345 if cls._token_cache and time_time() + 25 < cls._token_expiration:
346 return cls._token_cache
Aaron Gable92e9f382017-12-07 11:47:41 -0800347
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000348 resp, contents = cls._get(cls._ACQUIRE_URL,
349 headers=cls._ACQUIRE_HEADERS)
350 if resp is None or resp.status != 200:
351 return None
352 cls._token_cache = json.loads(contents)
353 cls._token_expiration = cls._token_cache['expires_in'] + time_time()
354 return cls._token_cache
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000355
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000356 def get_auth_header(self, _host):
357 token_dict = self._get_token_dict()
358 if not token_dict:
359 return None
360 return '%(token_type)s %(access_token)s' % token_dict
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000361
362
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700363class LuciContextAuthenticator(Authenticator):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000364 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700365 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000366 @staticmethod
367 def is_luci():
368 return auth.has_luci_context_local_auth()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700369
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000370 def __init__(self):
371 self._authenticator = auth.Authenticator(' '.join(
372 [auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700373
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000374 def get_auth_header(self, _host):
375 return 'Bearer %s' % self._authenticator.get_access_token().token
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700376
377
Ben Pastene04182552023-04-13 20:10:21 +0000378def CreateHttpConn(host,
379 path,
380 reqtype='GET',
381 headers=None,
382 body=None,
Xinan Lin1344a3c2023-05-02 21:55:43 +0000383 timeout=300):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000384 """Opens an HTTPS connection to a Gerrit service, and sends a request."""
385 headers = headers or {}
386 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000387
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000388 a = Authenticator.get()
389 # TODO(crbug.com/1059384): Automatically detect when running on cloudtop.
390 if isinstance(a, GceAuthenticator):
391 print('If you\'re on a cloudtop instance, export '
392 'SKIP_GCE_AUTH_FOR_GIT=1 in your env.')
Edward Lemur447507e2020-03-31 17:33:54 +0000393
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000394 a = a.get_auth_header(bare_host)
395 if a:
396 headers.setdefault('Authorization', a)
397 else:
398 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000399
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000400 url = path
401 if not url.startswith('/'):
402 url = '/' + url
403 if 'Authorization' in headers and not url.startswith('/a/'):
404 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000405
szager@chromium.orgb4696232013-10-16 19:45:35 +0000406 if body:
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000407 body = json.dumps(body, sort_keys=True)
408 headers.setdefault('Content-Type', 'application/json')
409 if LOGGER.isEnabledFor(logging.DEBUG):
410 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
411 for key, val in headers.items():
412 if key == 'Authorization':
413 val = 'HIDDEN'
414 LOGGER.debug('%s: %s' % (key, val))
415 if body:
416 LOGGER.debug(body)
417 conn = httplib2.Http(timeout=timeout)
418 # HACK: httplib2.Http has no such attribute; we store req_host here for
419 # later use in ReadHttpResponse.
420 conn.req_host = host
421 conn.req_params = {
422 'uri': urllib.parse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
423 'method': reqtype,
424 'headers': headers,
425 'body': body,
426 }
427 return conn
szager@chromium.orgb4696232013-10-16 19:45:35 +0000428
429
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700430def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000431 """Reads an HTTP response from a connection into a string buffer.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000432
433 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100434 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700435 accept_statuses: Treat any of these statuses as success. Default: [200]
436 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000437 Returns: A string buffer containing the connection's reply.
438 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000439 sleep_time = SLEEP_TIME
440 for idx in range(TRY_LIMIT):
441 before_response = time.time()
442 try:
443 response, contents = conn.request(**conn.req_params)
444 except socket.timeout:
445 if idx < TRY_LIMIT - 1:
446 sleep_time = log_retry_and_sleep(sleep_time, idx)
447 continue
448 raise
449 contents = contents.decode('utf-8', 'replace')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000450
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000451 response_time = time.time() - before_response
452 metrics.collector.add_repeated(
453 'http_requests',
454 metrics_utils.extract_http_metrics(conn.req_params['uri'],
455 conn.req_params['method'],
456 response.status, response_time))
Edward Lemur5a9ff432018-10-30 19:00:22 +0000457
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000458 # If response.status is an accepted status,
459 # or response.status < 500 then the result is final; break retry loop.
460 # If the response is 404/409 it might be because of replication lag,
461 # so keep trying anyway. If it is 429, it is generally ok to retry after
462 # a backoff.
463 if (response.status in accept_statuses or response.status < 500
464 and response.status not in [404, 409, 429]):
465 LOGGER.debug('got response %d for %s %s', response.status,
466 conn.req_params['method'], conn.req_params['uri'])
467 # If 404 was in accept_statuses, then it's expected that the file
468 # might not exist, so don't return the gitiles error page because
469 # that's not the "content" that was actually requested.
470 if response.status == 404:
471 contents = ''
472 break
Edward Lemur4ba192e2019-10-28 20:19:37 +0000473
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000474 # A status >=500 is assumed to be a possible transient error; retry.
475 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
476 LOGGER.warn(
477 'A transient error occurred while querying %s:\n'
478 '%s %s %s\n'
479 '%s %d %s\n'
480 '%s', conn.req_host, conn.req_params['method'],
481 conn.req_params['uri'], http_version, http_version, response.status,
482 response.reason, contents)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000483
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000484 if idx < TRY_LIMIT - 1:
485 sleep_time = log_retry_and_sleep(sleep_time, idx)
486 # end of retries loop
Edward Lemur4ba192e2019-10-28 20:19:37 +0000487
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000488 if response.status in accept_statuses:
489 return StringIO(contents)
Edward Lemur4ba192e2019-10-28 20:19:37 +0000490
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000491 if response.status in (302, 401, 403):
492 www_authenticate = response.get('www-authenticate')
493 if not www_authenticate:
494 print('Your Gerrit credentials might be misconfigured.')
495 else:
496 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
497 host = auth_match.group(1) if auth_match else conn.req_host
498 print('Authentication failed. Please make sure your .gitcookies '
499 'file has credentials for %s.' % host)
500 print('Try:\n git cl creds-check')
Edward Lemur4ba192e2019-10-28 20:19:37 +0000501
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000502 reason = '%s: %s' % (response.reason, contents)
503 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000504
505
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700506def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000507 """Parses an https response as json."""
508 fh = ReadHttpResponse(conn, accept_statuses)
509 # The first line of the response should always be: )]}'
510 s = fh.readline()
511 if s and s.rstrip() != ")]}'":
512 raise GerritError(200, 'Unexpected json output: %s' % s)
513 s = fh.read()
514 if not s:
515 return None
516 return json.loads(s)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000517
518
Michael Moss9c28af42021-10-25 16:59:05 +0000519def CallGerritApi(host, path, **kwargs):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000520 """Helper for calling a Gerrit API that returns a JSON response."""
521 conn_kwargs = {}
522 conn_kwargs.update(
523 (k, kwargs[k]) for k in ['reqtype', 'headers', 'body'] if k in kwargs)
524 conn = CreateHttpConn(host, path, **conn_kwargs)
525 read_kwargs = {}
526 read_kwargs.update(
527 (k, kwargs[k]) for k in ['accept_statuses'] if k in kwargs)
528 return ReadHttpJsonResponse(conn, **read_kwargs)
Michael Moss9c28af42021-10-25 16:59:05 +0000529
530
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000531def QueryChanges(host,
532 params,
533 first_param=None,
534 limit=None,
535 o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100536 start=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000537 """
szager@chromium.orgb4696232013-10-16 19:45:35 +0000538 Queries a gerrit-on-borg server for changes matching query terms.
539
540 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200541 params: A list of key:value pairs for search parameters, as documented
542 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
543 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000544 first_param: A change identifier
545 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100546 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000547 o_params: A list of additional output specifiers, as documented here:
548 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000549
szager@chromium.orgb4696232013-10-16 19:45:35 +0000550 Returns:
551 A list of json-decoded query results.
552 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000553 # Note that no attempt is made to escape special characters; YMMV.
554 if not params and not first_param:
555 raise RuntimeError('QueryChanges requires search parameters')
556 path = 'changes/?q=%s' % _QueryString(params, first_param)
557 if start:
558 path = '%s&start=%s' % (path, start)
559 if limit:
560 path = '%s&n=%d' % (path, limit)
561 if o_params:
562 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
563 return ReadHttpJsonResponse(CreateHttpConn(host, path, timeout=30.0))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000564
565
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000566def GenerateAllChanges(host,
567 params,
568 first_param=None,
569 limit=500,
570 o_params=None,
571 start=None):
572 """Queries a gerrit-on-borg server for all the changes matching the query
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000573 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000574
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100575 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000576 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100577
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000578 A single query to gerrit-on-borg is limited on the number of results by the
579 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100580 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000581
582 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200583 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000584 limit: Maximum number of requested changes per query.
585 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100586 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000587
588 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100589 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000590 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000591 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000592
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000593 def at_most_once(cls):
594 for cl in cls:
595 if cl['_number'] not in already_returned:
596 already_returned.add(cl['_number'])
597 yield cl
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100598
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000599 start = start or 0
600 cur_start = start
601 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100602
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000603 while more_changes:
604 # This will fetch changes[start..start+limit] sorted by most recently
605 # updated. Since the rank of any change in this list can be changed any
606 # time (say user posting comment), subsequent calls may overalp like
607 # this: > initial order ABCDEFGH query[0..3] => ABC > E gets updated.
608 # New order: EABCDFGH query[3..6] => CDF # C is a dup query[6..9] =>
609 # GH # E is missed.
610 page = QueryChanges(host, params, first_param, limit, o_params,
611 cur_start)
612 for cl in at_most_once(page):
613 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000614
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000615 more_changes = [cl for cl in page if '_more_changes' in cl]
616 if len(more_changes) > 1:
617 raise GerritError(
618 200,
619 'Received %d changes with a _more_changes attribute set but should '
620 'receive at most one.' % len(more_changes))
621 if more_changes:
622 cur_start += len(page)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100623
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000624 # If we paged through, query again the first page which in most
625 # circumstances will fetch all changes that were modified while this
626 # function was run.
627 if start != cur_start:
628 page = QueryChanges(host, params, first_param, limit, o_params, start)
629 for cl in at_most_once(page):
630 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000631
632
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000633def MultiQueryChanges(host,
634 params,
635 change_list,
636 limit=None,
637 o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100638 start=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000639 """Initiate a query composed of multiple sets of query parameters."""
640 if not change_list:
641 raise RuntimeError(
642 "MultiQueryChanges requires a list of change numbers/id's")
643 q = [
644 'q=%s' % '+OR+'.join([urllib.parse.quote(str(x)) for x in change_list])
645 ]
646 if params:
647 q.append(_QueryString(params))
648 if limit:
649 q.append('n=%d' % limit)
650 if start:
651 q.append('S=%s' % start)
652 if o_params:
653 q.extend(['o=%s' % p for p in o_params])
654 path = 'changes/?%s' % '&'.join(q)
655 try:
656 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
657 except GerritError as e:
658 msg = '%s:\n%s' % (e.message, path)
659 raise GerritError(e.http_status, msg)
660 return result
szager@chromium.orgb4696232013-10-16 19:45:35 +0000661
662
663def GetGerritFetchUrl(host):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000664 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
665 return '%s://%s/' % (GERRIT_PROTOCOL, host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000666
667
Edward Lemur687ca902018-12-05 02:30:30 +0000668def GetCodeReviewTbrScore(host, project):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000669 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000670 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000671 conn = CreateHttpConn(host,
672 '/projects/%s' % urllib.parse.quote(project, ''))
673 project = ReadHttpJsonResponse(conn)
674 if ('labels' not in project or 'Code-Review' not in project['labels']
675 or 'values' not in project['labels']['Code-Review']):
676 return 1
677 return max([int(x) for x in project['labels']['Code-Review']['values']])
Edward Lemur687ca902018-12-05 02:30:30 +0000678
679
szager@chromium.orgb4696232013-10-16 19:45:35 +0000680def GetChangePageUrl(host, change_number):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000681 """Given a Gerrit host name and change number, returns change page URL."""
682 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000683
684
685def GetChangeUrl(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000686 """Given a Gerrit host name and change ID, returns a URL for the change."""
687 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000688
689
690def GetChange(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000691 """Queries a Gerrit server for information about a single change."""
692 path = 'changes/%s' % change
693 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000694
695
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700696def GetChangeDetail(host, change, o_params=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000697 """Queries a Gerrit server for extended information about a single change."""
698 path = 'changes/%s/detail' % change
699 if o_params:
700 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
701 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000702
703
agable32978d92016-11-01 12:55:02 -0700704def GetChangeCommit(host, change, revision='current'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000705 """Query a Gerrit server for a revision associated with a change."""
706 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
707 return ReadHttpJsonResponse(CreateHttpConn(host, path))
agable32978d92016-11-01 12:55:02 -0700708
709
szager@chromium.orgb4696232013-10-16 19:45:35 +0000710def GetChangeCurrentRevision(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000711 """Get information about the latest revision for a given change."""
712 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION', ))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000713
714
715def GetChangeRevisions(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000716 """Gets information about all revisions associated with a change."""
717 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS', ))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000718
719
720def GetChangeReview(host, change, revision=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000721 """Gets the current review information for a change."""
722 if not revision:
723 jmsg = GetChangeRevisions(host, change)
724 if not jmsg:
725 return None
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +0000726
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000727 if len(jmsg) > 1:
728 raise GerritError(
729 200, 'Multiple changes found for ChangeId %s.' % change)
730 revision = jmsg[0]['current_revision']
731 path = 'changes/%s/revisions/%s/review'
732 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000733
734
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700735def GetChangeComments(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000736 """Get the line- and file-level comments on a change."""
737 path = 'changes/%s/comments' % change
738 return ReadHttpJsonResponse(CreateHttpConn(host, path))
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700739
740
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000741def GetChangeRobotComments(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000742 """Gets the line- and file-level robot comments on a change."""
743 path = 'changes/%s/robotcomments' % change
744 return ReadHttpJsonResponse(CreateHttpConn(host, path))
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000745
746
Marco Georgaklis85557a02021-06-03 15:56:54 +0000747def GetRelatedChanges(host, change, revision='current'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000748 """Gets the related changes for a given change and revision."""
749 path = 'changes/%s/revisions/%s/related' % (change, revision)
750 return ReadHttpJsonResponse(CreateHttpConn(host, path))
Marco Georgaklis85557a02021-06-03 15:56:54 +0000751
752
szager@chromium.orgb4696232013-10-16 19:45:35 +0000753def AbandonChange(host, change, msg=''):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000754 """Abandons a Gerrit change."""
755 path = 'changes/%s/abandon' % change
756 body = {'message': msg} if msg else {}
757 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
758 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000759
760
Josip Sokcevicc39ab992020-09-24 20:09:15 +0000761def MoveChange(host, change, destination_branch):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000762 """Move a Gerrit change to different destination branch."""
763 path = 'changes/%s/move' % change
764 body = {'destination_branch': destination_branch, 'keep_all_votes': True}
765 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
766 return ReadHttpJsonResponse(conn)
Josip Sokcevicc39ab992020-09-24 20:09:15 +0000767
768
szager@chromium.orgb4696232013-10-16 19:45:35 +0000769def RestoreChange(host, change, msg=''):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000770 """Restores a previously abandoned change."""
771 path = 'changes/%s/restore' % change
772 body = {'message': msg} if msg else {}
773 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
774 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000775
776
Xinan Lin1bd4ffa2021-07-28 00:54:22 +0000777def SubmitChange(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000778 """Submits a Gerrit change via Gerrit."""
779 path = 'changes/%s/submit' % change
780 conn = CreateHttpConn(host, path, reqtype='POST')
781 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000782
783
Xinan Lin2b4ec952021-08-20 17:35:29 +0000784def GetChangesSubmittedTogether(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000785 """Get all changes submitted with the given one."""
786 path = 'changes/%s/submitted_together?o=NON_VISIBLE_CHANGES' % change
787 conn = CreateHttpConn(host, path, reqtype='GET')
788 return ReadHttpJsonResponse(conn)
Xinan Lin2b4ec952021-08-20 17:35:29 +0000789
790
LaMont Jones9eed4232021-04-02 16:29:49 +0000791def PublishChangeEdit(host, change, notify=True):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000792 """Publish a Gerrit change edit."""
793 path = 'changes/%s/edit:publish' % change
794 body = {'notify': 'ALL' if notify else 'NONE'}
795 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
796 return ReadHttpJsonResponse(conn, accept_statuses=(204, ))
LaMont Jones9eed4232021-04-02 16:29:49 +0000797
798
799def ChangeEdit(host, change, path, data):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000800 """Puts content of a file into a change edit."""
801 path = 'changes/%s/edit/%s' % (change, urllib.parse.quote(path, ''))
802 body = {
803 'binary_content':
804 'data:text/plain;base64,%s' %
805 base64.b64encode(data.encode('utf-8')).decode('utf-8')
806 }
807 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
808 return ReadHttpJsonResponse(conn, accept_statuses=(204, 409))
LaMont Jones9eed4232021-04-02 16:29:49 +0000809
810
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000811def SetChangeEditMessage(host, change, message):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000812 """Sets the commit message of a change edit."""
813 path = 'changes/%s/edit:message' % change
814 body = {'message': message}
815 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
816 return ReadHttpJsonResponse(conn, accept_statuses=(204, 409))
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000817
818
dsansomee2d6fd92016-09-08 00:10:47 -0700819def HasPendingChangeEdit(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000820 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
821 try:
822 ReadHttpResponse(conn)
823 except GerritError as e:
824 # 204 No Content means no pending change.
825 if e.http_status == 204:
826 return False
827 raise
828 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700829
830
831def DeletePendingChangeEdit(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000832 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
833 # On success, Gerrit returns status 204; if the edit was already deleted it
834 # returns 404. Anything else is an error.
835 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700836
837
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000838def CherryPick(host, change, destination, revision='current'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000839 """Create a cherry-pick commit from the given change, onto the given
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000840 destination.
841 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000842 path = 'changes/%s/revisions/%s/cherrypick' % (change, revision)
843 body = {'destination': destination}
844 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
845 return ReadHttpJsonResponse(conn)
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000846
847
848def GetFileContents(host, change, path):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000849 """Get the contents of a file with the given path in the given revision.
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000850
851 Returns:
852 A bytes object with the file's contents.
853 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000854 path = 'changes/%s/revisions/current/files/%s/content' % (
855 change, urllib.parse.quote(path, ''))
856 conn = CreateHttpConn(host, path, reqtype='GET')
857 return base64.b64decode(ReadHttpResponse(conn).read())
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000858
859
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100860def SetCommitMessage(host, change, description, notify='ALL'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000861 """Updates a commit message."""
862 assert notify in ('ALL', 'NONE')
863 path = 'changes/%s/message' % change
864 body = {'message': description, 'notify': notify}
865 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
866 try:
867 ReadHttpResponse(conn, accept_statuses=[200, 204])
868 except GerritError as e:
869 raise GerritError(
870 e.http_status,
871 'Received unexpected http status while editing message '
872 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000873
874
Xinan Linc2fb26a2021-07-27 18:01:55 +0000875def GetCommitIncludedIn(host, project, commit):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000876 """Retrieves the branches and tags for a given commit.
Xinan Linc2fb26a2021-07-27 18:01:55 +0000877
878 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-included-in
879
880 Returns:
881 A JSON object with keys of 'branches' and 'tags'.
882 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000883 path = 'projects/%s/commits/%s/in' % (urllib.parse.quote(project,
884 ''), commit)
885 conn = CreateHttpConn(host, path, reqtype='GET')
886 return ReadHttpJsonResponse(conn, accept_statuses=[200])
Xinan Linc2fb26a2021-07-27 18:01:55 +0000887
888
Edward Lesmes8170c292021-03-19 20:04:43 +0000889def IsCodeOwnersEnabledOnHost(host):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000890 """Check if the code-owners plugin is enabled for the host."""
891 path = 'config/server/capabilities'
892 capabilities = ReadHttpJsonResponse(CreateHttpConn(host, path))
893 return 'code-owners-checkCodeOwner' in capabilities
Edward Lesmes110823b2021-02-05 21:42:27 +0000894
895
Edward Lesmes8170c292021-03-19 20:04:43 +0000896def IsCodeOwnersEnabledOnRepo(host, repo):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000897 """Check if the code-owners plugin is enabled for the repo."""
898 repo = PercentEncodeForGitRef(repo)
899 path = '/projects/%s/code_owners.project_config' % repo
900 config = ReadHttpJsonResponse(CreateHttpConn(host, path))
901 return not config['status'].get('disabled', False)
Edward Lesmes8170c292021-03-19 20:04:43 +0000902
903
Gavin Make0fee9f2022-08-10 23:41:55 +0000904def GetOwnersForFile(host,
905 project,
906 branch,
907 path,
908 limit=100,
909 resolve_all_users=True,
910 highest_score_only=False,
911 seed=None,
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000912 o_params=('DETAILS', )):
913 """Gets information about owners attached to a file."""
914 path = 'projects/%s/branches/%s/code_owners/%s' % (urllib.parse.quote(
915 project, ''), urllib.parse.quote(branch,
916 ''), urllib.parse.quote(path, ''))
917 q = ['resolve-all-users=%s' % json.dumps(resolve_all_users)]
918 if highest_score_only:
919 q.append('highest-score-only=%s' % json.dumps(highest_score_only))
920 if seed:
921 q.append('seed=%d' % seed)
922 if limit:
923 q.append('n=%d' % limit)
924 if o_params:
925 q.extend(['o=%s' % p for p in o_params])
926 if q:
927 path = '%s?%s' % (path, '&'.join(q))
928 return ReadHttpJsonResponse(CreateHttpConn(host, path))
Gavin Makc94b21d2020-12-10 20:27:32 +0000929
930
szager@chromium.orgb4696232013-10-16 19:45:35 +0000931def GetReviewers(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000932 """Gets information about all reviewers attached to a change."""
933 path = 'changes/%s/reviewers' % change
934 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000935
936
937def GetReview(host, change, revision):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000938 """Gets review information about a specific revision of a change."""
939 path = 'changes/%s/revisions/%s/review' % (change, revision)
940 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000941
942
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000943def AddReviewers(host,
944 change,
945 reviewers=None,
946 ccs=None,
947 notify=True,
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700948 accept_statuses=frozenset([200, 400, 422])):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000949 """Add reviewers to a change."""
950 if not reviewers and not ccs:
951 return None
952 if not change:
953 return None
954 reviewers = frozenset(reviewers or [])
955 ccs = frozenset(ccs or [])
956 path = 'changes/%s/revisions/current/review' % change
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700957
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000958 body = {
959 'drafts': 'KEEP',
960 'reviewers': [],
961 'notify': 'ALL' if notify else 'NONE',
962 }
963 for r in sorted(reviewers | ccs):
964 state = 'REVIEWER' if r in reviewers else 'CC'
965 body['reviewers'].append({
966 'reviewer': r,
967 'state': state,
968 'notify': 'NONE', # We handled `notify` argument above.
969 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700970
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000971 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
972 # Gerrit will return 400 if one or more of the requested reviewers are
973 # unprocessable. We read the response object to see which were rejected,
974 # warn about them, and retry with the remainder.
975 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700976
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000977 errored = set()
978 for result in resp.get('reviewers', {}).values():
979 r = result.get('input')
980 state = 'REVIEWER' if r in reviewers else 'CC'
981 if result.get('error'):
982 errored.add(r)
983 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
984 if errored:
985 # Try again, adding only those that didn't fail, and only accepting 200.
986 AddReviewers(host,
987 change,
988 reviewers=(reviewers - errored),
989 ccs=(ccs - errored),
990 notify=notify,
991 accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000992
993
Aaron Gable636b13f2017-07-14 10:42:48 -0700994def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000995 """Sets labels and/or adds a message to a code review."""
996 if not msg and not labels:
997 return
998 path = 'changes/%s/revisions/current/review' % change
999 body = {'drafts': 'KEEP'}
1000 if msg:
1001 body['message'] = msg
1002 if labels:
1003 body['labels'] = labels
1004 if notify is not None:
1005 body['notify'] = 'ALL' if notify else 'NONE'
1006 if ready:
1007 body['ready'] = True
1008 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
1009 response = ReadHttpJsonResponse(conn)
1010 if labels:
1011 for key, val in labels.items():
1012 if ('labels' not in response or key not in response['labels']
1013 or int(response['labels'][key] != int(val))):
1014 raise GerritError(
1015 200,
1016 'Unable to set "%s" label on change %s.' % (key, change))
1017 return response
szager@chromium.orgb4696232013-10-16 19:45:35 +00001018
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001019
1020def ResetReviewLabels(host,
1021 change,
1022 label,
1023 value='0',
1024 message=None,
szager@chromium.orgb4696232013-10-16 19:45:35 +00001025 notify=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001026 """Resets the value of a given label for all reviewers on a change."""
1027 # This is tricky, because we want to work on the "current revision", but
1028 # there's always the risk that "current revision" will change in between
1029 # API calls. So, we check "current revision" at the beginning and end; if
1030 # it has changed, raise an exception.
1031 jmsg = GetChangeCurrentRevision(host, change)
1032 if not jmsg:
1033 raise GerritError(
1034 200, 'Could not get review information for change "%s"' % change)
1035 value = str(value)
1036 revision = jmsg[0]['current_revision']
1037 path = 'changes/%s/revisions/%s/review' % (change, revision)
1038 message = message or ('%s label set to %s programmatically.' %
1039 (label, value))
1040 jmsg = GetReview(host, change, revision)
1041 if not jmsg:
1042 raise GerritError(
1043 200, 'Could not get review information for revision %s '
1044 'of change %s' % (revision, change))
1045 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
1046 if str(review.get('value', value)) != value:
1047 body = {
1048 'drafts': 'KEEP',
1049 'message': message,
1050 'labels': {
1051 label: value
1052 },
1053 'on_behalf_of': review['_account_id'],
1054 }
1055 if notify:
1056 body['notify'] = notify
1057 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
1058 response = ReadHttpJsonResponse(conn)
1059 if str(response['labels'][label]) != value:
1060 username = review.get('email', jmsg.get('name', ''))
1061 raise GerritError(
1062 200, 'Unable to set %s label for user "%s"'
1063 ' on change %s.' % (label, username, change))
1064 jmsg = GetChangeCurrentRevision(host, change)
1065 if not jmsg:
1066 raise GerritError(
1067 200, 'Could not get review information for change "%s"' % change)
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +00001068
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001069 if jmsg[0]['current_revision'] != revision:
1070 raise GerritError(
1071 200, 'While resetting labels on change "%s", '
1072 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -08001073
1074
LaMont Jones9eed4232021-04-02 16:29:49 +00001075def CreateChange(host, project, branch='main', subject='', params=()):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001076 """
LaMont Jones9eed4232021-04-02 16:29:49 +00001077 Creates a new change.
1078
1079 Args:
1080 params: A list of additional ChangeInput specifiers, as documented here:
1081 (e.g. ('is_private', 'true') to mark the change private.
1082 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-input
1083
1084 Returns:
1085 ChangeInfo for the new change.
1086 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001087 path = 'changes/'
1088 body = {'project': project, 'branch': branch, 'subject': subject}
1089 body.update(dict(params))
1090 for key in 'project', 'branch', 'subject':
1091 if not body[key]:
1092 raise GerritError(200, '%s is required' % key.title())
LaMont Jones9eed4232021-04-02 16:29:49 +00001093
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001094 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
1095 return ReadHttpJsonResponse(conn, accept_statuses=[201])
LaMont Jones9eed4232021-04-02 16:29:49 +00001096
1097
dimu833c94c2017-01-18 17:36:15 -08001098def CreateGerritBranch(host, project, branch, commit):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001099 """Creates a new branch from given project and commit
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001100
dimu833c94c2017-01-18 17:36:15 -08001101 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
1102
1103 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001104 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -08001105 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001106 path = 'projects/%s/branches/%s' % (project, branch)
1107 body = {'revision': commit}
1108 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
1109 response = ReadHttpJsonResponse(conn, accept_statuses=[201, 409])
1110 if response:
1111 return response
1112 raise GerritError(200, 'Unable to create gerrit branch')
dimu833c94c2017-01-18 17:36:15 -08001113
1114
Michael Mossb6ce2442021-10-20 04:36:24 +00001115def CreateGerritTag(host, project, tag, commit):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001116 """Creates a new tag at the given commit.
Michael Mossb6ce2442021-10-20 04:36:24 +00001117
1118 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-tag
1119
1120 Returns:
1121 A JSON object with 'ref' key.
1122 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001123 path = 'projects/%s/tags/%s' % (project, tag)
1124 body = {'revision': commit}
1125 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
1126 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
1127 if response:
1128 return response
1129 raise GerritError(200, 'Unable to create gerrit tag')
Michael Mossb6ce2442021-10-20 04:36:24 +00001130
1131
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001132def GetHead(host, project):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001133 """Retrieves current HEAD of Gerrit project
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001134
1135 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-head
1136
1137 Returns:
1138 A JSON object with 'ref' key.
1139 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001140 path = 'projects/%s/HEAD' % (project)
1141 conn = CreateHttpConn(host, path, reqtype='GET')
1142 response = ReadHttpJsonResponse(conn, accept_statuses=[200])
1143 if response:
1144 return response
1145 raise GerritError(200, 'Unable to update gerrit HEAD')
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001146
1147
1148def UpdateHead(host, project, branch):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001149 """Updates Gerrit HEAD to point to branch
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001150
1151 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#set-head
1152
1153 Returns:
1154 A JSON object with 'ref' key.
1155 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001156 path = 'projects/%s/HEAD' % (project)
1157 body = {'ref': branch}
1158 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
1159 response = ReadHttpJsonResponse(conn, accept_statuses=[200])
1160 if response:
1161 return response
1162 raise GerritError(200, 'Unable to update gerrit HEAD')
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001163
1164
dimu833c94c2017-01-18 17:36:15 -08001165def GetGerritBranch(host, project, branch):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001166 """Gets a branch info from given project and branch name.
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001167
1168 See:
dimu833c94c2017-01-18 17:36:15 -08001169 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
1170
1171 Returns:
Xinan Linaf79f242021-08-09 21:23:58 +00001172 A JSON object with 'revision' key if the branch exists, otherwise None.
dimu833c94c2017-01-18 17:36:15 -08001173 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001174 path = 'projects/%s/branches/%s' % (project, branch)
1175 conn = CreateHttpConn(host, path, reqtype='GET')
1176 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
dimu833c94c2017-01-18 17:36:15 -08001177
1178
Josip Sokcevicf736cab2020-10-20 23:41:38 +00001179def GetProjectHead(host, project):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001180 conn = CreateHttpConn(host,
1181 '/projects/%s/HEAD' % urllib.parse.quote(project, ''))
1182 return ReadHttpJsonResponse(conn, accept_statuses=[200])
Josip Sokcevicf736cab2020-10-20 23:41:38 +00001183
1184
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +01001185def GetAccountDetails(host, account_id='self'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001186 """Returns details of the account.
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +01001187
1188 If account_id is not given, uses magic value 'self' which corresponds to
1189 whichever account user is authenticating as.
1190
1191 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001192 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +00001193
1194 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +01001195 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001196 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
1197 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +00001198
1199
1200def ValidAccounts(host, accounts, max_threads=10):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001201 """Returns a mapping from valid account to its details.
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +00001202
1203 Invalid accounts, either not existing or without unique match,
1204 are not present as returned dictionary keys.
1205 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001206 assert not isinstance(accounts, str), type(accounts)
1207 accounts = list(set(accounts))
1208 if not accounts:
1209 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001210
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001211 def get_one(account):
1212 try:
1213 return account, GetAccountDetails(host, account)
1214 except GerritError:
1215 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001216
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001217 valid = {}
1218 with contextlib.closing(ThreadPool(min(max_threads,
1219 len(accounts)))) as pool:
1220 for account, details in pool.map(get_one, accounts):
1221 if account and details:
1222 valid[account] = details
1223 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +01001224
1225
Nick Carter8692b182017-11-06 16:30:38 -08001226def PercentEncodeForGitRef(original):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001227 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -08001228
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001229 The encoding used is based on but stricter than URL encoding (Section 2.1 of
1230 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
1231 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -08001232
1233 For more information, see the Gerrit docs here:
1234
1235 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
1236 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001237 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
1238 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
Nick Carter8692b182017-11-06 16:30:38 -08001239
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001240 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
1241 # '+' (or '%20') as space. Use '_' since that has been supported the
1242 # longest.
1243 return encoded.replace(' ', '_')
Nick Carter8692b182017-11-06 16:30:38 -08001244
1245
Dan Jacques8d11e482016-11-15 14:25:56 -08001246@contextlib.contextmanager
1247def tempdir():
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001248 tdir = None
1249 try:
1250 tdir = tempfile.mkdtemp(suffix='gerrit_util')
1251 yield tdir
1252 finally:
1253 if tdir:
1254 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001255
1256
1257def ChangeIdentifier(project, change_number):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001258 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001259 this module API.
1260
1261 Such format is allows for more efficient Gerrit routing of HTTP requests,
1262 comparing to specifying just change_number.
1263 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001264 assert int(change_number)
1265 return '%s~%s' % (urllib.parse.quote(project, ''), change_number)