blob: cd5f28205484a8701da575c30a4c34dcff7064f1 [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
Gavin Mak7f5b53f2023-09-07 18:13:01 +000024import urllib.parse
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000025from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000026
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070027import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080028import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000029import metrics
30import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080031import subprocess2
szager@chromium.orgf202a252014-05-27 18:55:52 +000032
Gavin Makcc976552023-08-28 17:01:52 +000033import http.cookiejar
34from io import StringIO
Edward Lemur4ba192e2019-10-28 20:19:37 +000035
Mike Frysinger124bb8e2023-09-06 05:48:55 +000036# TODO: Should fix these warnings.
37# pylint: disable=line-too-long
38
szager@chromium.orgb4696232013-10-16 19:45:35 +000039LOGGER = logging.getLogger()
Gavin Makfc75af32023-06-20 20:06:27 +000040# With a starting sleep time of 12.0 seconds, x <= [1.8-2.2]x backoff, and six
41# total tries, the sleep time between the first and last tries will be ~6 min
42# (excluding time for each try).
43TRY_LIMIT = 6
44SLEEP_TIME = 12.0
George Engelbrecht888c0fe2020-04-17 15:00:20 +000045MAX_BACKOFF = 2.2
46MIN_BACKOFF = 1.8
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000047
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +000048# Controls the transport protocol used to communicate with Gerrit.
szager@chromium.orgb4696232013-10-16 19:45:35 +000049# This is parameterized primarily to enable GerritTestCase.
50GERRIT_PROTOCOL = 'https'
51
52
Edward Lemur4ba192e2019-10-28 20:19:37 +000053def time_sleep(seconds):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000054 # Use this so that it can be mocked in tests without interfering with python
55 # system machinery.
56 return time.sleep(seconds)
Edward Lemur4ba192e2019-10-28 20:19:37 +000057
58
Edward Lemura3b6fd02020-03-02 22:16:15 +000059def time_time():
Mike Frysinger124bb8e2023-09-06 05:48:55 +000060 # Use this so that it can be mocked in tests without interfering with python
61 # system machinery.
62 return time.time()
Edward Lemura3b6fd02020-03-02 22:16:15 +000063
64
Ben Pastene9519fc12023-04-12 22:15:43 +000065def log_retry_and_sleep(seconds, attempt):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000066 LOGGER.info('Will retry in %d seconds (%d more times)...', seconds,
67 TRY_LIMIT - attempt - 1)
68 time_sleep(seconds)
69 return seconds * random.uniform(MIN_BACKOFF, MAX_BACKOFF)
Ben Pastene9519fc12023-04-12 22:15:43 +000070
71
szager@chromium.orgb4696232013-10-16 19:45:35 +000072class GerritError(Exception):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000073 """Exception class for errors commuicating with the gerrit-on-borg service."""
74 def __init__(self, http_status, message, *args, **kwargs):
75 super(GerritError, self).__init__(*args, **kwargs)
76 self.http_status = http_status
77 self.message = '(%d) %s' % (self.http_status, message)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000078
Mike Frysinger124bb8e2023-09-06 05:48:55 +000079 def __str__(self):
80 return self.message
Josip Sokcevicdf9a8022020-12-08 00:10:19 +000081
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000082
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020083def _QueryString(params, first_param=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000084 """Encodes query parameters in the key:val[+key:val...] format specified here:
szager@chromium.orgb4696232013-10-16 19:45:35 +000085
86 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
87 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000088 q = [urllib.parse.quote(first_param)] if first_param else []
89 q.extend(['%s:%s' % (key, val.replace(" ", "+")) for key, val in params])
90 return '+'.join(q)
szager@chromium.orgb4696232013-10-16 19:45:35 +000091
92
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000093class Authenticator(object):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000094 """Base authenticator class for authenticator implementations to subclass."""
95 def get_auth_header(self, host):
96 raise NotImplementedError()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000097
Mike Frysinger124bb8e2023-09-06 05:48:55 +000098 @staticmethod
99 def get():
100 """Returns: (Authenticator) The identified Authenticator to use.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000101
102 Probes the local system and its environment and identifies the
103 Authenticator instance to use.
104 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000105 # LUCI Context takes priority since it's normally present only on bots,
106 # which then must use it.
107 if LuciContextAuthenticator.is_luci():
108 return LuciContextAuthenticator()
109 # TODO(crbug.com/1059384): Automatically detect when running on
110 # cloudtop, and use CookiesAuthenticator instead.
111 if GceAuthenticator.is_gce():
112 return GceAuthenticator()
113 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000114
115
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000116class CookiesAuthenticator(Authenticator):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000117 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000118
119 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000120 """
121
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000122 _EMPTY = object()
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000123
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000124 def __init__(self):
125 # Credentials will be loaded lazily on first use. This ensures
126 # Authenticator get() can always construct an authenticator, even if
127 # something is broken. This allows 'creds-check' to proceed to actually
128 # checking creds later, rigorously (instead of blowing up with a cryptic
129 # error if they are wrong).
130 self._netrc = self._EMPTY
131 self._gitcookies = self._EMPTY
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000132
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000133 @property
134 def netrc(self):
135 if self._netrc is self._EMPTY:
136 self._netrc = self._get_netrc()
137 return self._netrc
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000138
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000139 @property
140 def gitcookies(self):
141 if self._gitcookies is self._EMPTY:
142 self._gitcookies = self._get_gitcookies()
143 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000144
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000145 @classmethod
146 def get_new_password_url(cls, host):
147 assert not host.startswith('http')
148 # Assume *.googlesource.com pattern.
149 parts = host.split('.')
Aravind Vasudevana02b4bf2023-02-03 17:52:03 +0000150
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000151 # remove -review suffix if present.
152 if parts[0].endswith('-review'):
153 parts[0] = parts[0][:-len('-review')]
Aravind Vasudevana02b4bf2023-02-03 17:52:03 +0000154
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000155 return 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200156
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000157 @classmethod
158 def get_new_password_message(cls, host):
159 if host is None:
160 return ('Git host for Gerrit upload is unknown. Check your remote '
161 'and the branch your branch is tracking. This tool assumes '
162 'that you are using a git server at *.googlesource.com.')
163 url = cls.get_new_password_url(host)
164 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000165
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000166 @classmethod
167 def get_netrc_path(cls):
168 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
169 return os.path.expanduser(os.path.join('~', path))
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000170
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000171 @classmethod
172 def _get_netrc(cls):
173 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
174 path = cls.get_netrc_path()
175 if not os.path.exists(path):
176 return netrc.netrc(os.devnull)
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000177
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000178 st = os.stat(path)
179 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
180 print('WARNING: netrc file %s cannot be used because its file '
181 'permissions are insecure. netrc file permissions should be '
182 '600.' % path,
183 file=sys.stderr)
184 with open(path) as fd:
185 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800186
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000187 # Load the '.netrc' file. We strip comments from it because processing
188 # them can trigger a bug in Windows. See crbug.com/664664.
189 content = '\n'.join(l for l in content.splitlines()
190 if l.strip() and not l.strip().startswith('#'))
191 with tempdir() as tdir:
192 netrc_path = os.path.join(tdir, 'netrc')
193 with open(netrc_path, 'w') as fd:
194 fd.write(content)
195 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
196 return cls._get_netrc_from_path(netrc_path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800197
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000198 @classmethod
199 def _get_netrc_from_path(cls, path):
200 try:
201 return netrc.netrc(path)
202 except IOError:
203 print('WARNING: Could not read netrc file %s' % path,
204 file=sys.stderr)
205 return netrc.netrc(os.devnull)
206 except netrc.NetrcParseError as e:
207 print('ERROR: Cannot use netrc file %s due to a parsing error: %s' %
208 (path, e),
209 file=sys.stderr)
210 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000211
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000212 @classmethod
213 def get_gitcookies_path(cls):
214 if os.getenv('GIT_COOKIES_PATH'):
215 return os.getenv('GIT_COOKIES_PATH')
216 try:
217 path = subprocess2.check_output(
218 ['git', 'config', '--path', 'http.cookiefile'])
219 return path.decode('utf-8', 'ignore').strip()
220 except subprocess2.CalledProcessError:
221 return os.path.expanduser(os.path.join('~', '.gitcookies'))
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000222
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000223 @classmethod
224 def _get_gitcookies(cls):
225 gitcookies = {}
226 path = cls.get_gitcookies_path()
227 if not os.path.exists(path):
228 return gitcookies
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000229
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000230 try:
231 f = gclient_utils.FileRead(path, 'rb').splitlines()
232 except IOError:
233 return gitcookies
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000234
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000235 for line in f:
236 try:
237 fields = line.strip().split('\t')
238 if line.strip().startswith('#') or len(fields) != 7:
239 continue
240 domain, xpath, key, value = fields[0], fields[2], fields[
241 5], fields[6]
242 if xpath == '/' and key == 'o':
243 if value.startswith('git-'):
244 login, secret_token = value.split('=', 1)
245 gitcookies[domain] = (login, secret_token)
246 else:
247 gitcookies[domain] = ('', value)
248 except (IndexError, ValueError, TypeError) as exc:
249 LOGGER.warning(exc)
250 return gitcookies
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000251
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000252 def _get_auth_for_host(self, host):
253 for domain, creds in self.gitcookies.items():
254 if http.cookiejar.domain_match(host, domain):
255 return (creds[0], None, creds[1])
256 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000257
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000258 def get_auth_header(self, host):
259 a = self._get_auth_for_host(host)
260 if a:
261 if a[0]:
262 secret = base64.b64encode(
263 ('%s:%s' % (a[0], a[2])).encode('utf-8'))
264 return 'Basic %s' % secret.decode('utf-8')
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +0000265
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000266 return 'Bearer %s' % a[2]
267 return None
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000268
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000269 def get_auth_email(self, host):
270 """Best effort parsing of email to be used for auth for the given host."""
271 a = self._get_auth_for_host(host)
272 if not a:
273 return None
274 login = a[0]
275 # login typically looks like 'git-xxx.example.com'
276 if not login.startswith('git-') or '.' not in login:
277 return None
278 username, domain = login[len('git-'):].split('.', 1)
279 return '%s@%s' % (username, domain)
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100280
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100281
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000282# Backwards compatibility just in case somebody imports this outside of
283# depot_tools.
284NetrcAuthenticator = CookiesAuthenticator
285
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000286
287class GceAuthenticator(Authenticator):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000288 """Authenticator implementation that uses GCE metadata service for token.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000289 """
290
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000291 _INFO_URL = 'http://metadata.google.internal'
292 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
293 'service-accounts/default/token' % _INFO_URL)
294 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000295
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000296 _cache_is_gce = None
297 _token_cache = None
298 _token_expiration = None
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000299
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000300 @classmethod
301 def is_gce(cls):
302 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
303 return False
304 if cls._cache_is_gce is None:
305 cls._cache_is_gce = cls._test_is_gce()
306 return cls._cache_is_gce
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000307
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000308 @classmethod
309 def _test_is_gce(cls):
310 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
311 resp, _ = cls._get(cls._INFO_URL)
312 if resp is None:
313 return False
314 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000315
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000316 @staticmethod
317 def _get(url, **kwargs):
318 next_delay_sec = 1.0
319 for i in range(TRY_LIMIT):
320 p = urllib.parse.urlparse(url)
321 if p.scheme not in ('http', 'https'):
322 raise RuntimeError("Don't know how to work with protocol '%s'" %
323 protocol)
324 try:
325 resp, contents = httplib2.Http().request(url, 'GET', **kwargs)
326 except (socket.error, httplib2.HttpLib2Error,
327 httplib2.socks.ProxyError) as e:
328 LOGGER.debug('GET [%s] raised %s', url, e)
329 return None, None
330 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i + 1, TRY_LIMIT,
331 resp.status)
332 if resp.status < 500:
333 return (resp, contents)
334
335 # Retry server error status codes.
336 LOGGER.warn('Encountered server error')
337 if TRY_LIMIT - i > 1:
338 next_delay_sec = log_retry_and_sleep(next_delay_sec, i)
Edward Lemura3b6fd02020-03-02 22:16:15 +0000339 return None, None
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000340
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000341 @classmethod
342 def _get_token_dict(cls):
343 # If cached token is valid for at least 25 seconds, return it.
344 if cls._token_cache and time_time() + 25 < cls._token_expiration:
345 return cls._token_cache
Aaron Gable92e9f382017-12-07 11:47:41 -0800346
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000347 resp, contents = cls._get(cls._ACQUIRE_URL,
348 headers=cls._ACQUIRE_HEADERS)
349 if resp is None or resp.status != 200:
350 return None
351 cls._token_cache = json.loads(contents)
352 cls._token_expiration = cls._token_cache['expires_in'] + time_time()
353 return cls._token_cache
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000354
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000355 def get_auth_header(self, _host):
356 token_dict = self._get_token_dict()
357 if not token_dict:
358 return None
359 return '%(token_type)s %(access_token)s' % token_dict
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000360
361
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700362class LuciContextAuthenticator(Authenticator):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000363 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700364 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000365 @staticmethod
366 def is_luci():
367 return auth.has_luci_context_local_auth()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700368
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000369 def __init__(self):
370 self._authenticator = auth.Authenticator(' '.join(
371 [auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700372
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000373 def get_auth_header(self, _host):
374 return 'Bearer %s' % self._authenticator.get_access_token().token
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700375
376
Ben Pastene04182552023-04-13 20:10:21 +0000377def CreateHttpConn(host,
378 path,
379 reqtype='GET',
380 headers=None,
381 body=None,
Xinan Lin1344a3c2023-05-02 21:55:43 +0000382 timeout=300):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000383 """Opens an HTTPS connection to a Gerrit service, and sends a request."""
384 headers = headers or {}
385 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000386
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000387 a = Authenticator.get()
388 # TODO(crbug.com/1059384): Automatically detect when running on cloudtop.
389 if isinstance(a, GceAuthenticator):
390 print('If you\'re on a cloudtop instance, export '
391 'SKIP_GCE_AUTH_FOR_GIT=1 in your env.')
Edward Lemur447507e2020-03-31 17:33:54 +0000392
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000393 a = a.get_auth_header(bare_host)
394 if a:
395 headers.setdefault('Authorization', a)
396 else:
397 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000398
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000399 url = path
400 if not url.startswith('/'):
401 url = '/' + url
402 if 'Authorization' in headers and not url.startswith('/a/'):
403 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000404
szager@chromium.orgb4696232013-10-16 19:45:35 +0000405 if body:
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000406 body = json.dumps(body, sort_keys=True)
407 headers.setdefault('Content-Type', 'application/json')
408 if LOGGER.isEnabledFor(logging.DEBUG):
409 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
410 for key, val in headers.items():
411 if key == 'Authorization':
412 val = 'HIDDEN'
413 LOGGER.debug('%s: %s' % (key, val))
414 if body:
415 LOGGER.debug(body)
416 conn = httplib2.Http(timeout=timeout)
417 # HACK: httplib2.Http has no such attribute; we store req_host here for
418 # later use in ReadHttpResponse.
419 conn.req_host = host
420 conn.req_params = {
421 'uri': urllib.parse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
422 'method': reqtype,
423 'headers': headers,
424 'body': body,
425 }
426 return conn
szager@chromium.orgb4696232013-10-16 19:45:35 +0000427
428
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700429def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000430 """Reads an HTTP response from a connection into a string buffer.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000431
432 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100433 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700434 accept_statuses: Treat any of these statuses as success. Default: [200]
435 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000436 Returns: A string buffer containing the connection's reply.
437 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000438 sleep_time = SLEEP_TIME
439 for idx in range(TRY_LIMIT):
440 before_response = time.time()
441 try:
442 response, contents = conn.request(**conn.req_params)
443 except socket.timeout:
444 if idx < TRY_LIMIT - 1:
445 sleep_time = log_retry_and_sleep(sleep_time, idx)
446 continue
447 raise
448 contents = contents.decode('utf-8', 'replace')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000449
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000450 response_time = time.time() - before_response
451 metrics.collector.add_repeated(
452 'http_requests',
453 metrics_utils.extract_http_metrics(conn.req_params['uri'],
454 conn.req_params['method'],
455 response.status, response_time))
Edward Lemur5a9ff432018-10-30 19:00:22 +0000456
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000457 # If response.status is an accepted status,
458 # or response.status < 500 then the result is final; break retry loop.
459 # If the response is 404/409 it might be because of replication lag,
460 # so keep trying anyway. If it is 429, it is generally ok to retry after
461 # a backoff.
462 if (response.status in accept_statuses or response.status < 500
463 and response.status not in [404, 409, 429]):
464 LOGGER.debug('got response %d for %s %s', response.status,
465 conn.req_params['method'], conn.req_params['uri'])
466 # If 404 was in accept_statuses, then it's expected that the file
467 # might not exist, so don't return the gitiles error page because
468 # that's not the "content" that was actually requested.
469 if response.status == 404:
470 contents = ''
471 break
Edward Lemur4ba192e2019-10-28 20:19:37 +0000472
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000473 # A status >=500 is assumed to be a possible transient error; retry.
474 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
475 LOGGER.warn(
476 'A transient error occurred while querying %s:\n'
477 '%s %s %s\n'
478 '%s %d %s\n'
479 '%s', conn.req_host, conn.req_params['method'],
480 conn.req_params['uri'], http_version, http_version, response.status,
481 response.reason, contents)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000482
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000483 if idx < TRY_LIMIT - 1:
484 sleep_time = log_retry_and_sleep(sleep_time, idx)
485 # end of retries loop
Edward Lemur4ba192e2019-10-28 20:19:37 +0000486
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000487 if response.status in accept_statuses:
488 return StringIO(contents)
Edward Lemur4ba192e2019-10-28 20:19:37 +0000489
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000490 if response.status in (302, 401, 403):
491 www_authenticate = response.get('www-authenticate')
492 if not www_authenticate:
493 print('Your Gerrit credentials might be misconfigured.')
494 else:
495 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
496 host = auth_match.group(1) if auth_match else conn.req_host
497 print('Authentication failed. Please make sure your .gitcookies '
498 'file has credentials for %s.' % host)
499 print('Try:\n git cl creds-check')
Edward Lemur4ba192e2019-10-28 20:19:37 +0000500
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000501 reason = '%s: %s' % (response.reason, contents)
502 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000503
504
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700505def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000506 """Parses an https response as json."""
507 fh = ReadHttpResponse(conn, accept_statuses)
508 # The first line of the response should always be: )]}'
509 s = fh.readline()
510 if s and s.rstrip() != ")]}'":
511 raise GerritError(200, 'Unexpected json output: %s' % s)
512 s = fh.read()
513 if not s:
514 return None
515 return json.loads(s)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000516
517
Michael Moss9c28af42021-10-25 16:59:05 +0000518def CallGerritApi(host, path, **kwargs):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000519 """Helper for calling a Gerrit API that returns a JSON response."""
520 conn_kwargs = {}
521 conn_kwargs.update(
522 (k, kwargs[k]) for k in ['reqtype', 'headers', 'body'] if k in kwargs)
523 conn = CreateHttpConn(host, path, **conn_kwargs)
524 read_kwargs = {}
525 read_kwargs.update(
526 (k, kwargs[k]) for k in ['accept_statuses'] if k in kwargs)
527 return ReadHttpJsonResponse(conn, **read_kwargs)
Michael Moss9c28af42021-10-25 16:59:05 +0000528
529
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000530def QueryChanges(host,
531 params,
532 first_param=None,
533 limit=None,
534 o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100535 start=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000536 """
szager@chromium.orgb4696232013-10-16 19:45:35 +0000537 Queries a gerrit-on-borg server for changes matching query terms.
538
539 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200540 params: A list of key:value pairs for search parameters, as documented
541 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
542 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000543 first_param: A change identifier
544 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100545 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000546 o_params: A list of additional output specifiers, as documented here:
547 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000548
szager@chromium.orgb4696232013-10-16 19:45:35 +0000549 Returns:
550 A list of json-decoded query results.
551 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000552 # Note that no attempt is made to escape special characters; YMMV.
553 if not params and not first_param:
554 raise RuntimeError('QueryChanges requires search parameters')
555 path = 'changes/?q=%s' % _QueryString(params, first_param)
556 if start:
557 path = '%s&start=%s' % (path, start)
558 if limit:
559 path = '%s&n=%d' % (path, limit)
560 if o_params:
561 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
562 return ReadHttpJsonResponse(CreateHttpConn(host, path, timeout=30.0))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000563
564
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000565def GenerateAllChanges(host,
566 params,
567 first_param=None,
568 limit=500,
569 o_params=None,
570 start=None):
571 """Queries a gerrit-on-borg server for all the changes matching the query
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000572 terms.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000573
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100574 WARNING: this is unreliable if a change matching the query is modified while
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000575 this function is being called.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100576
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000577 A single query to gerrit-on-borg is limited on the number of results by the
578 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100579 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000580
581 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200582 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000583 limit: Maximum number of requested changes per query.
584 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100585 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000586
587 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100588 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000589 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000590 already_returned = set()
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +0000591
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000592 def at_most_once(cls):
593 for cl in cls:
594 if cl['_number'] not in already_returned:
595 already_returned.add(cl['_number'])
596 yield cl
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100597
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000598 start = start or 0
599 cur_start = start
600 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100601
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000602 while more_changes:
603 # This will fetch changes[start..start+limit] sorted by most recently
604 # updated. Since the rank of any change in this list can be changed any
605 # time (say user posting comment), subsequent calls may overalp like
606 # this: > initial order ABCDEFGH query[0..3] => ABC > E gets updated.
607 # New order: EABCDFGH query[3..6] => CDF # C is a dup query[6..9] =>
608 # GH # E is missed.
609 page = QueryChanges(host, params, first_param, limit, o_params,
610 cur_start)
611 for cl in at_most_once(page):
612 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000613
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000614 more_changes = [cl for cl in page if '_more_changes' in cl]
615 if len(more_changes) > 1:
616 raise GerritError(
617 200,
618 'Received %d changes with a _more_changes attribute set but should '
619 'receive at most one.' % len(more_changes))
620 if more_changes:
621 cur_start += len(page)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100622
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000623 # If we paged through, query again the first page which in most
624 # circumstances will fetch all changes that were modified while this
625 # function was run.
626 if start != cur_start:
627 page = QueryChanges(host, params, first_param, limit, o_params, start)
628 for cl in at_most_once(page):
629 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000630
631
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000632def MultiQueryChanges(host,
633 params,
634 change_list,
635 limit=None,
636 o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100637 start=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000638 """Initiate a query composed of multiple sets of query parameters."""
639 if not change_list:
640 raise RuntimeError(
641 "MultiQueryChanges requires a list of change numbers/id's")
642 q = [
643 'q=%s' % '+OR+'.join([urllib.parse.quote(str(x)) for x in change_list])
644 ]
645 if params:
646 q.append(_QueryString(params))
647 if limit:
648 q.append('n=%d' % limit)
649 if start:
650 q.append('S=%s' % start)
651 if o_params:
652 q.extend(['o=%s' % p for p in o_params])
653 path = 'changes/?%s' % '&'.join(q)
654 try:
655 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
656 except GerritError as e:
657 msg = '%s:\n%s' % (e.message, path)
658 raise GerritError(e.http_status, msg)
659 return result
szager@chromium.orgb4696232013-10-16 19:45:35 +0000660
661
662def GetGerritFetchUrl(host):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000663 """Given a Gerrit host name returns URL of a Gerrit instance to fetch from."""
664 return '%s://%s/' % (GERRIT_PROTOCOL, host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000665
666
Edward Lemur687ca902018-12-05 02:30:30 +0000667def GetCodeReviewTbrScore(host, project):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000668 """Given a Gerrit host name and project, return the Code-Review score for TBR.
Edward Lemur687ca902018-12-05 02:30:30 +0000669 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000670 conn = CreateHttpConn(host,
671 '/projects/%s' % urllib.parse.quote(project, ''))
672 project = ReadHttpJsonResponse(conn)
673 if ('labels' not in project or 'Code-Review' not in project['labels']
674 or 'values' not in project['labels']['Code-Review']):
675 return 1
676 return max([int(x) for x in project['labels']['Code-Review']['values']])
Edward Lemur687ca902018-12-05 02:30:30 +0000677
678
szager@chromium.orgb4696232013-10-16 19:45:35 +0000679def GetChangePageUrl(host, change_number):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000680 """Given a Gerrit host name and change number, returns change page URL."""
681 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000682
683
684def GetChangeUrl(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000685 """Given a Gerrit host name and change ID, returns a URL for the change."""
686 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000687
688
689def GetChange(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000690 """Queries a Gerrit server for information about a single change."""
691 path = 'changes/%s' % change
692 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000693
694
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700695def GetChangeDetail(host, change, o_params=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000696 """Queries a Gerrit server for extended information about a single change."""
697 path = 'changes/%s/detail' % change
698 if o_params:
699 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
700 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000701
702
agable32978d92016-11-01 12:55:02 -0700703def GetChangeCommit(host, change, revision='current'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000704 """Query a Gerrit server for a revision associated with a change."""
705 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
706 return ReadHttpJsonResponse(CreateHttpConn(host, path))
agable32978d92016-11-01 12:55:02 -0700707
708
szager@chromium.orgb4696232013-10-16 19:45:35 +0000709def GetChangeCurrentRevision(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000710 """Get information about the latest revision for a given change."""
711 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION', ))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000712
713
714def GetChangeRevisions(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000715 """Gets information about all revisions associated with a change."""
716 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS', ))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000717
718
719def GetChangeReview(host, change, revision=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000720 """Gets the current review information for a change."""
721 if not revision:
722 jmsg = GetChangeRevisions(host, change)
723 if not jmsg:
724 return None
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +0000725
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000726 if len(jmsg) > 1:
727 raise GerritError(
728 200, 'Multiple changes found for ChangeId %s.' % change)
729 revision = jmsg[0]['current_revision']
730 path = 'changes/%s/revisions/%s/review'
731 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000732
733
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700734def GetChangeComments(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000735 """Get the line- and file-level comments on a change."""
736 path = 'changes/%s/comments' % change
737 return ReadHttpJsonResponse(CreateHttpConn(host, path))
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700738
739
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000740def GetChangeRobotComments(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000741 """Gets the line- and file-level robot comments on a change."""
742 path = 'changes/%s/robotcomments' % change
743 return ReadHttpJsonResponse(CreateHttpConn(host, path))
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000744
745
Marco Georgaklis85557a02021-06-03 15:56:54 +0000746def GetRelatedChanges(host, change, revision='current'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000747 """Gets the related changes for a given change and revision."""
748 path = 'changes/%s/revisions/%s/related' % (change, revision)
749 return ReadHttpJsonResponse(CreateHttpConn(host, path))
Marco Georgaklis85557a02021-06-03 15:56:54 +0000750
751
szager@chromium.orgb4696232013-10-16 19:45:35 +0000752def AbandonChange(host, change, msg=''):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000753 """Abandons a Gerrit change."""
754 path = 'changes/%s/abandon' % change
755 body = {'message': msg} if msg else {}
756 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
757 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000758
759
Josip Sokcevicc39ab992020-09-24 20:09:15 +0000760def MoveChange(host, change, destination_branch):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000761 """Move a Gerrit change to different destination branch."""
762 path = 'changes/%s/move' % change
763 body = {'destination_branch': destination_branch, 'keep_all_votes': True}
764 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
765 return ReadHttpJsonResponse(conn)
Josip Sokcevicc39ab992020-09-24 20:09:15 +0000766
767
szager@chromium.orgb4696232013-10-16 19:45:35 +0000768def RestoreChange(host, change, msg=''):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000769 """Restores a previously abandoned change."""
770 path = 'changes/%s/restore' % change
771 body = {'message': msg} if msg else {}
772 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
773 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000774
775
Xinan Lin1bd4ffa2021-07-28 00:54:22 +0000776def SubmitChange(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000777 """Submits a Gerrit change via Gerrit."""
778 path = 'changes/%s/submit' % change
779 conn = CreateHttpConn(host, path, reqtype='POST')
780 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000781
782
Xinan Lin2b4ec952021-08-20 17:35:29 +0000783def GetChangesSubmittedTogether(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000784 """Get all changes submitted with the given one."""
785 path = 'changes/%s/submitted_together?o=NON_VISIBLE_CHANGES' % change
786 conn = CreateHttpConn(host, path, reqtype='GET')
787 return ReadHttpJsonResponse(conn)
Xinan Lin2b4ec952021-08-20 17:35:29 +0000788
789
LaMont Jones9eed4232021-04-02 16:29:49 +0000790def PublishChangeEdit(host, change, notify=True):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000791 """Publish a Gerrit change edit."""
792 path = 'changes/%s/edit:publish' % change
793 body = {'notify': 'ALL' if notify else 'NONE'}
794 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
795 return ReadHttpJsonResponse(conn, accept_statuses=(204, ))
LaMont Jones9eed4232021-04-02 16:29:49 +0000796
797
798def ChangeEdit(host, change, path, data):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000799 """Puts content of a file into a change edit."""
800 path = 'changes/%s/edit/%s' % (change, urllib.parse.quote(path, ''))
801 body = {
802 'binary_content':
803 'data:text/plain;base64,%s' %
804 base64.b64encode(data.encode('utf-8')).decode('utf-8')
805 }
806 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
807 return ReadHttpJsonResponse(conn, accept_statuses=(204, 409))
LaMont Jones9eed4232021-04-02 16:29:49 +0000808
809
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000810def SetChangeEditMessage(host, change, message):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000811 """Sets the commit message of a change edit."""
812 path = 'changes/%s/edit:message' % change
813 body = {'message': message}
814 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
815 return ReadHttpJsonResponse(conn, accept_statuses=(204, 409))
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000816
817
dsansomee2d6fd92016-09-08 00:10:47 -0700818def HasPendingChangeEdit(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000819 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
820 try:
821 ReadHttpResponse(conn)
822 except GerritError as e:
823 # 204 No Content means no pending change.
824 if e.http_status == 204:
825 return False
826 raise
827 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700828
829
830def DeletePendingChangeEdit(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000831 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
832 # On success, Gerrit returns status 204; if the edit was already deleted it
833 # returns 404. Anything else is an error.
834 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700835
836
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000837def CherryPick(host, change, destination, revision='current'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000838 """Create a cherry-pick commit from the given change, onto the given
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000839 destination.
840 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000841 path = 'changes/%s/revisions/%s/cherrypick' % (change, revision)
842 body = {'destination': destination}
843 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
844 return ReadHttpJsonResponse(conn)
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000845
846
847def GetFileContents(host, change, path):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000848 """Get the contents of a file with the given path in the given revision.
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000849
850 Returns:
851 A bytes object with the file's contents.
852 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000853 path = 'changes/%s/revisions/current/files/%s/content' % (
854 change, urllib.parse.quote(path, ''))
855 conn = CreateHttpConn(host, path, reqtype='GET')
856 return base64.b64decode(ReadHttpResponse(conn).read())
Leszek Swirskic1c45f82022-06-09 16:21:07 +0000857
858
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100859def SetCommitMessage(host, change, description, notify='ALL'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000860 """Updates a commit message."""
861 assert notify in ('ALL', 'NONE')
862 path = 'changes/%s/message' % change
863 body = {'message': description, 'notify': notify}
864 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
865 try:
866 ReadHttpResponse(conn, accept_statuses=[200, 204])
867 except GerritError as e:
868 raise GerritError(
869 e.http_status,
870 'Received unexpected http status while editing message '
871 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000872
873
Xinan Linc2fb26a2021-07-27 18:01:55 +0000874def GetCommitIncludedIn(host, project, commit):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000875 """Retrieves the branches and tags for a given commit.
Xinan Linc2fb26a2021-07-27 18:01:55 +0000876
877 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-included-in
878
879 Returns:
880 A JSON object with keys of 'branches' and 'tags'.
881 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000882 path = 'projects/%s/commits/%s/in' % (urllib.parse.quote(project,
883 ''), commit)
884 conn = CreateHttpConn(host, path, reqtype='GET')
885 return ReadHttpJsonResponse(conn, accept_statuses=[200])
Xinan Linc2fb26a2021-07-27 18:01:55 +0000886
887
Edward Lesmes8170c292021-03-19 20:04:43 +0000888def IsCodeOwnersEnabledOnHost(host):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000889 """Check if the code-owners plugin is enabled for the host."""
890 path = 'config/server/capabilities'
891 capabilities = ReadHttpJsonResponse(CreateHttpConn(host, path))
892 return 'code-owners-checkCodeOwner' in capabilities
Edward Lesmes110823b2021-02-05 21:42:27 +0000893
894
Edward Lesmes8170c292021-03-19 20:04:43 +0000895def IsCodeOwnersEnabledOnRepo(host, repo):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000896 """Check if the code-owners plugin is enabled for the repo."""
897 repo = PercentEncodeForGitRef(repo)
898 path = '/projects/%s/code_owners.project_config' % repo
899 config = ReadHttpJsonResponse(CreateHttpConn(host, path))
900 return not config['status'].get('disabled', False)
Edward Lesmes8170c292021-03-19 20:04:43 +0000901
902
Gavin Make0fee9f2022-08-10 23:41:55 +0000903def GetOwnersForFile(host,
904 project,
905 branch,
906 path,
907 limit=100,
908 resolve_all_users=True,
909 highest_score_only=False,
910 seed=None,
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000911 o_params=('DETAILS', )):
912 """Gets information about owners attached to a file."""
913 path = 'projects/%s/branches/%s/code_owners/%s' % (urllib.parse.quote(
914 project, ''), urllib.parse.quote(branch,
915 ''), urllib.parse.quote(path, ''))
916 q = ['resolve-all-users=%s' % json.dumps(resolve_all_users)]
917 if highest_score_only:
918 q.append('highest-score-only=%s' % json.dumps(highest_score_only))
919 if seed:
920 q.append('seed=%d' % seed)
921 if limit:
922 q.append('n=%d' % limit)
923 if o_params:
924 q.extend(['o=%s' % p for p in o_params])
925 if q:
926 path = '%s?%s' % (path, '&'.join(q))
927 return ReadHttpJsonResponse(CreateHttpConn(host, path))
Gavin Makc94b21d2020-12-10 20:27:32 +0000928
929
szager@chromium.orgb4696232013-10-16 19:45:35 +0000930def GetReviewers(host, change):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000931 """Gets information about all reviewers attached to a change."""
932 path = 'changes/%s/reviewers' % change
933 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000934
935
936def GetReview(host, change, revision):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000937 """Gets review information about a specific revision of a change."""
938 path = 'changes/%s/revisions/%s/review' % (change, revision)
939 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000940
941
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000942def AddReviewers(host,
943 change,
944 reviewers=None,
945 ccs=None,
946 notify=True,
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700947 accept_statuses=frozenset([200, 400, 422])):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000948 """Add reviewers to a change."""
949 if not reviewers and not ccs:
950 return None
951 if not change:
952 return None
953 reviewers = frozenset(reviewers or [])
954 ccs = frozenset(ccs or [])
955 path = 'changes/%s/revisions/current/review' % change
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700956
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000957 body = {
958 'drafts': 'KEEP',
959 'reviewers': [],
960 'notify': 'ALL' if notify else 'NONE',
961 }
962 for r in sorted(reviewers | ccs):
963 state = 'REVIEWER' if r in reviewers else 'CC'
964 body['reviewers'].append({
965 'reviewer': r,
966 'state': state,
967 'notify': 'NONE', # We handled `notify` argument above.
968 })
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700969
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000970 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
971 # Gerrit will return 400 if one or more of the requested reviewers are
972 # unprocessable. We read the response object to see which were rejected,
973 # warn about them, and retry with the remainder.
974 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700975
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000976 errored = set()
977 for result in resp.get('reviewers', {}).values():
978 r = result.get('input')
979 state = 'REVIEWER' if r in reviewers else 'CC'
980 if result.get('error'):
981 errored.add(r)
982 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
983 if errored:
984 # Try again, adding only those that didn't fail, and only accepting 200.
985 AddReviewers(host,
986 change,
987 reviewers=(reviewers - errored),
988 ccs=(ccs - errored),
989 notify=notify,
990 accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000991
992
Aaron Gable636b13f2017-07-14 10:42:48 -0700993def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000994 """Sets labels and/or adds a message to a code review."""
995 if not msg and not labels:
996 return
997 path = 'changes/%s/revisions/current/review' % change
998 body = {'drafts': 'KEEP'}
999 if msg:
1000 body['message'] = msg
1001 if labels:
1002 body['labels'] = labels
1003 if notify is not None:
1004 body['notify'] = 'ALL' if notify else 'NONE'
1005 if ready:
1006 body['ready'] = True
1007 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
1008 response = ReadHttpJsonResponse(conn)
1009 if labels:
1010 for key, val in labels.items():
1011 if ('labels' not in response or key not in response['labels']
1012 or int(response['labels'][key] != int(val))):
1013 raise GerritError(
1014 200,
1015 'Unable to set "%s" label on change %s.' % (key, change))
1016 return response
szager@chromium.orgb4696232013-10-16 19:45:35 +00001017
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001018
1019def ResetReviewLabels(host,
1020 change,
1021 label,
1022 value='0',
1023 message=None,
szager@chromium.orgb4696232013-10-16 19:45:35 +00001024 notify=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001025 """Resets the value of a given label for all reviewers on a change."""
1026 # This is tricky, because we want to work on the "current revision", but
1027 # there's always the risk that "current revision" will change in between
1028 # API calls. So, we check "current revision" at the beginning and end; if
1029 # it has changed, raise an exception.
1030 jmsg = GetChangeCurrentRevision(host, change)
1031 if not jmsg:
1032 raise GerritError(
1033 200, 'Could not get review information for change "%s"' % change)
1034 value = str(value)
1035 revision = jmsg[0]['current_revision']
1036 path = 'changes/%s/revisions/%s/review' % (change, revision)
1037 message = message or ('%s label set to %s programmatically.' %
1038 (label, value))
1039 jmsg = GetReview(host, change, revision)
1040 if not jmsg:
1041 raise GerritError(
1042 200, 'Could not get review information for revision %s '
1043 'of change %s' % (revision, change))
1044 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
1045 if str(review.get('value', value)) != value:
1046 body = {
1047 'drafts': 'KEEP',
1048 'message': message,
1049 'labels': {
1050 label: value
1051 },
1052 'on_behalf_of': review['_account_id'],
1053 }
1054 if notify:
1055 body['notify'] = notify
1056 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
1057 response = ReadHttpJsonResponse(conn)
1058 if str(response['labels'][label]) != value:
1059 username = review.get('email', jmsg.get('name', ''))
1060 raise GerritError(
1061 200, 'Unable to set %s label for user "%s"'
1062 ' on change %s.' % (label, username, change))
1063 jmsg = GetChangeCurrentRevision(host, change)
1064 if not jmsg:
1065 raise GerritError(
1066 200, 'Could not get review information for change "%s"' % change)
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +00001067
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001068 if jmsg[0]['current_revision'] != revision:
1069 raise GerritError(
1070 200, 'While resetting labels on change "%s", '
1071 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -08001072
1073
LaMont Jones9eed4232021-04-02 16:29:49 +00001074def CreateChange(host, project, branch='main', subject='', params=()):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001075 """
LaMont Jones9eed4232021-04-02 16:29:49 +00001076 Creates a new change.
1077
1078 Args:
1079 params: A list of additional ChangeInput specifiers, as documented here:
1080 (e.g. ('is_private', 'true') to mark the change private.
1081 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-input
1082
1083 Returns:
1084 ChangeInfo for the new change.
1085 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001086 path = 'changes/'
1087 body = {'project': project, 'branch': branch, 'subject': subject}
1088 body.update(dict(params))
1089 for key in 'project', 'branch', 'subject':
1090 if not body[key]:
1091 raise GerritError(200, '%s is required' % key.title())
LaMont Jones9eed4232021-04-02 16:29:49 +00001092
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001093 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
1094 return ReadHttpJsonResponse(conn, accept_statuses=[201])
LaMont Jones9eed4232021-04-02 16:29:49 +00001095
1096
dimu833c94c2017-01-18 17:36:15 -08001097def CreateGerritBranch(host, project, branch, commit):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001098 """Creates a new branch from given project and commit
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001099
dimu833c94c2017-01-18 17:36:15 -08001100 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
1101
1102 Returns:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001103 A JSON object with 'ref' key.
dimu833c94c2017-01-18 17:36:15 -08001104 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001105 path = 'projects/%s/branches/%s' % (project, branch)
1106 body = {'revision': commit}
1107 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
1108 response = ReadHttpJsonResponse(conn, accept_statuses=[201, 409])
1109 if response:
1110 return response
1111 raise GerritError(200, 'Unable to create gerrit branch')
dimu833c94c2017-01-18 17:36:15 -08001112
1113
Michael Mossb6ce2442021-10-20 04:36:24 +00001114def CreateGerritTag(host, project, tag, commit):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001115 """Creates a new tag at the given commit.
Michael Mossb6ce2442021-10-20 04:36:24 +00001116
1117 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-tag
1118
1119 Returns:
1120 A JSON object with 'ref' key.
1121 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001122 path = 'projects/%s/tags/%s' % (project, tag)
1123 body = {'revision': commit}
1124 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
1125 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
1126 if response:
1127 return response
1128 raise GerritError(200, 'Unable to create gerrit tag')
Michael Mossb6ce2442021-10-20 04:36:24 +00001129
1130
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001131def GetHead(host, project):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001132 """Retrieves current HEAD of Gerrit project
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001133
1134 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-head
1135
1136 Returns:
1137 A JSON object with 'ref' key.
1138 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001139 path = 'projects/%s/HEAD' % (project)
1140 conn = CreateHttpConn(host, path, reqtype='GET')
1141 response = ReadHttpJsonResponse(conn, accept_statuses=[200])
1142 if response:
1143 return response
1144 raise GerritError(200, 'Unable to update gerrit HEAD')
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001145
1146
1147def UpdateHead(host, project, branch):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001148 """Updates Gerrit HEAD to point to branch
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001149
1150 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#set-head
1151
1152 Returns:
1153 A JSON object with 'ref' key.
1154 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001155 path = 'projects/%s/HEAD' % (project)
1156 body = {'ref': branch}
1157 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
1158 response = ReadHttpJsonResponse(conn, accept_statuses=[200])
1159 if response:
1160 return response
1161 raise GerritError(200, 'Unable to update gerrit HEAD')
Josip Sokcevicdf9a8022020-12-08 00:10:19 +00001162
1163
dimu833c94c2017-01-18 17:36:15 -08001164def GetGerritBranch(host, project, branch):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001165 """Gets a branch info from given project and branch name.
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001166
1167 See:
dimu833c94c2017-01-18 17:36:15 -08001168 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
1169
1170 Returns:
Xinan Linaf79f242021-08-09 21:23:58 +00001171 A JSON object with 'revision' key if the branch exists, otherwise None.
dimu833c94c2017-01-18 17:36:15 -08001172 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001173 path = 'projects/%s/branches/%s' % (project, branch)
1174 conn = CreateHttpConn(host, path, reqtype='GET')
1175 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
dimu833c94c2017-01-18 17:36:15 -08001176
1177
Josip Sokcevicf736cab2020-10-20 23:41:38 +00001178def GetProjectHead(host, project):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001179 conn = CreateHttpConn(host,
1180 '/projects/%s/HEAD' % urllib.parse.quote(project, ''))
1181 return ReadHttpJsonResponse(conn, accept_statuses=[200])
Josip Sokcevicf736cab2020-10-20 23:41:38 +00001182
1183
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +01001184def GetAccountDetails(host, account_id='self'):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001185 """Returns details of the account.
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +01001186
1187 If account_id is not given, uses magic value 'self' which corresponds to
1188 whichever account user is authenticating as.
1189
1190 Documentation:
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001191 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +00001192
1193 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +01001194 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001195 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
1196 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +00001197
1198
1199def ValidAccounts(host, accounts, max_threads=10):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001200 """Returns a mapping from valid account to its details.
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +00001201
1202 Invalid accounts, either not existing or without unique match,
1203 are not present as returned dictionary keys.
1204 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001205 assert not isinstance(accounts, str), type(accounts)
1206 accounts = list(set(accounts))
1207 if not accounts:
1208 return {}
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001209
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001210 def get_one(account):
1211 try:
1212 return account, GetAccountDetails(host, account)
1213 except GerritError:
1214 return None, None
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001215
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001216 valid = {}
1217 with contextlib.closing(ThreadPool(min(max_threads,
1218 len(accounts)))) as pool:
1219 for account, details in pool.map(get_one, accounts):
1220 if account and details:
1221 valid[account] = details
1222 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +01001223
1224
Nick Carter8692b182017-11-06 16:30:38 -08001225def PercentEncodeForGitRef(original):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001226 """Applies percent-encoding for strings sent to Gerrit via git ref metadata.
Nick Carter8692b182017-11-06 16:30:38 -08001227
Quinten Yearsleyd9cbe7a2019-09-03 16:49:11 +00001228 The encoding used is based on but stricter than URL encoding (Section 2.1 of
1229 RFC 3986). The only non-escaped characters are alphanumerics, and 'SPACE'
1230 (U+0020) can be represented as 'LOW LINE' (U+005F) or 'PLUS SIGN' (U+002B).
Nick Carter8692b182017-11-06 16:30:38 -08001231
1232 For more information, see the Gerrit docs here:
1233
1234 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
1235 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001236 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
1237 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
Nick Carter8692b182017-11-06 16:30:38 -08001238
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001239 # Spaces are not allowed in git refs; gerrit will interpret either '_' or
1240 # '+' (or '%20') as space. Use '_' since that has been supported the
1241 # longest.
1242 return encoded.replace(' ', '_')
Nick Carter8692b182017-11-06 16:30:38 -08001243
1244
Dan Jacques8d11e482016-11-15 14:25:56 -08001245@contextlib.contextmanager
1246def tempdir():
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001247 tdir = None
1248 try:
1249 tdir = tempfile.mkdtemp(suffix='gerrit_util')
1250 yield tdir
1251 finally:
1252 if tdir:
1253 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001254
1255
1256def ChangeIdentifier(project, change_number):
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001257 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +00001258 this module API.
1259
1260 Such format is allows for more efficient Gerrit routing of HTTP requests,
1261 comparing to specifying just change_number.
1262 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001263 assert int(change_number)
1264 return '%s~%s' % (urllib.parse.quote(project, ''), change_number)