blob: 1923b6ad5434856aa6b7b9390dbc40dc70489e4d [file] [log] [blame]
szager@chromium.orgb4696232013-10-16 19:45:35 +00001# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""
6Utilities for requesting information for a gerrit server via https.
7
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
11import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080012import contextlib
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000013import cookielib
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010014import httplib # Still used for its constants.
szager@chromium.orgb4696232013-10-16 19:45:35 +000015import json
16import logging
17import netrc
18import os
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +000019import random
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000020import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000021import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000022import stat
23import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080024import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000025import time
26import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000027import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000028from cStringIO import StringIO
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +000029from multiprocessing.pool import ThreadPool
szager@chromium.orgb4696232013-10-16 19:45:35 +000030
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070031import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080032import gclient_utils
Edward Lemur5a9ff432018-10-30 19:00:22 +000033import metrics
34import metrics_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080035import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010036from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000037
szager@chromium.orgb4696232013-10-16 19:45:35 +000038LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000039# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
40# total tries, the sleep time between the first and last tries will be 94.5 sec.
41# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
42TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000043
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000044
szager@chromium.orgb4696232013-10-16 19:45:35 +000045# Controls the transport protocol used to communicate with gerrit.
46# This is parameterized primarily to enable GerritTestCase.
47GERRIT_PROTOCOL = 'https'
48
49
50class GerritError(Exception):
51 """Exception class for errors commuicating with the gerrit-on-borg service."""
52 def __init__(self, http_status, *args, **kwargs):
53 super(GerritError, self).__init__(*args, **kwargs)
54 self.http_status = http_status
55 self.message = '(%d) %s' % (self.http_status, self.message)
56
57
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000058class GerritAuthenticationError(GerritError):
59 """Exception class for authentication errors during Gerrit communication."""
60
61
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020062def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000063 """Encodes query parameters in the key:val[+key:val...] format specified here:
64
65 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
66 """
67 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020068 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000069 return '+'.join(q)
70
71
Aaron Gabled2db5a22017-03-24 14:14:15 -070072def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000073 if protocol is None:
74 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010075 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070076 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000077 else:
78 raise RuntimeError(
79 "Don't know how to work with protocol '%s'" % protocol)
80
81
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000082class Authenticator(object):
83 """Base authenticator class for authenticator implementations to subclass."""
84
85 def get_auth_header(self, host):
86 raise NotImplementedError()
87
88 @staticmethod
89 def get():
90 """Returns: (Authenticator) The identified Authenticator to use.
91
92 Probes the local system and its environment and identifies the
93 Authenticator instance to use.
94 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070095 # LUCI Context takes priority since it's normally present only on bots,
96 # which then must use it.
97 if LuciContextAuthenticator.is_luci():
98 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000099 if GceAuthenticator.is_gce():
100 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000101 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000102
103
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000104class CookiesAuthenticator(Authenticator):
105 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
106
107 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000108 """
109
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000110 _EMPTY = object()
111
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000112 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000113 # Credentials will be loaded lazily on first use. This ensures Authenticator
114 # get() can always construct an authenticator, even if something is broken.
115 # This allows 'creds-check' to proceed to actually checking creds later,
116 # rigorously (instead of blowing up with a cryptic error if they are wrong).
117 self._netrc = self._EMPTY
118 self._gitcookies = self._EMPTY
119
120 @property
121 def netrc(self):
122 if self._netrc is self._EMPTY:
123 self._netrc = self._get_netrc()
124 return self._netrc
125
126 @property
127 def gitcookies(self):
128 if self._gitcookies is self._EMPTY:
129 self._gitcookies = self._get_gitcookies()
130 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000131
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000132 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200133 def get_new_password_url(cls, host):
134 assert not host.startswith('http')
135 # Assume *.googlesource.com pattern.
136 parts = host.split('.')
137 if not parts[0].endswith('-review'):
138 parts[0] += '-review'
139 return 'https://%s/new-password' % ('.'.join(parts))
140
141 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000142 def get_new_password_message(cls, host):
143 assert not host.startswith('http')
144 # Assume *.googlesource.com pattern.
145 parts = host.split('.')
146 if not parts[0].endswith('-review'):
147 parts[0] += '-review'
148 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100149 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000150
151 @classmethod
152 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000153 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000154 return os.path.expanduser(os.path.join('~', path))
155
156 @classmethod
157 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800158 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000159 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000160 if not os.path.exists(path):
161 return netrc.netrc(os.devnull)
162
163 st = os.stat(path)
164 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
165 print >> sys.stderr, (
166 'WARNING: netrc file %s cannot be used because its file '
167 'permissions are insecure. netrc file permissions should be '
168 '600.' % path)
169 with open(path) as fd:
170 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800171
172 # Load the '.netrc' file. We strip comments from it because processing them
173 # can trigger a bug in Windows. See crbug.com/664664.
174 content = '\n'.join(l for l in content.splitlines()
175 if l.strip() and not l.strip().startswith('#'))
176 with tempdir() as tdir:
177 netrc_path = os.path.join(tdir, 'netrc')
178 with open(netrc_path, 'w') as fd:
179 fd.write(content)
180 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
181 return cls._get_netrc_from_path(netrc_path)
182
183 @classmethod
184 def _get_netrc_from_path(cls, path):
185 try:
186 return netrc.netrc(path)
187 except IOError:
188 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
189 return netrc.netrc(os.devnull)
190 except netrc.NetrcParseError as e:
191 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
192 'parsing error: %s' % (path, e))
193 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000194
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000195 @classmethod
196 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500197 if os.getenv('GIT_COOKIES_PATH'):
198 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800199 try:
200 return subprocess2.check_output(
201 ['git', 'config', '--path', 'http.cookiefile']).strip()
202 except subprocess2.CalledProcessError:
203 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000204
205 @classmethod
206 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000207 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000208 path = cls.get_gitcookies_path()
209 if not os.path.exists(path):
210 return gitcookies
211
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000212 try:
213 f = open(path, 'rb')
214 except IOError:
215 return gitcookies
216
217 with f:
218 for line in f:
219 try:
220 fields = line.strip().split('\t')
221 if line.strip().startswith('#') or len(fields) != 7:
222 continue
223 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
224 if xpath == '/' and key == 'o':
Eric Boren2fb63102018-10-05 13:05:03 +0000225 if value.startswith('git-'):
226 login, secret_token = value.split('=', 1)
227 gitcookies[domain] = (login, secret_token)
228 else:
229 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000230 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100231 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000232 return gitcookies
233
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100234 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000235 for domain, creds in self.gitcookies.iteritems():
236 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100237 return (creds[0], None, creds[1])
238 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000239
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100240 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700241 a = self._get_auth_for_host(host)
242 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000243 if a[0]:
244 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
245 else:
246 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000247 return None
248
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100249 def get_auth_email(self, host):
250 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700251 a = self._get_auth_for_host(host)
252 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100253 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700254 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100255 # login typically looks like 'git-xxx.example.com'
256 if not login.startswith('git-') or '.' not in login:
257 return None
258 username, domain = login[len('git-'):].split('.', 1)
259 return '%s@%s' % (username, domain)
260
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100261
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000262# Backwards compatibility just in case somebody imports this outside of
263# depot_tools.
264NetrcAuthenticator = CookiesAuthenticator
265
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000266
267class GceAuthenticator(Authenticator):
268 """Authenticator implementation that uses GCE metadata service for token.
269 """
270
271 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700272 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
273 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000274 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
275
276 _cache_is_gce = None
277 _token_cache = None
278 _token_expiration = None
279
280 @classmethod
281 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500282 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
283 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000284 if cls._cache_is_gce is None:
285 cls._cache_is_gce = cls._test_is_gce()
286 return cls._cache_is_gce
287
288 @classmethod
289 def _test_is_gce(cls):
290 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
291 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100292 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100293 except (socket.error, httplib2.ServerNotFoundError,
294 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000295 # Could not resolve URL.
296 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100297 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000298
299 @staticmethod
300 def _get(url, **kwargs):
301 next_delay_sec = 1
302 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700304 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100305 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000306 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
307 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100308 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000309
Aaron Gable92e9f382017-12-07 11:47:41 -0800310 # Retry server error status codes.
311 LOGGER.warn('Encountered server error')
312 if TRY_LIMIT - i > 1:
313 LOGGER.info('Will retry in %d seconds (%d more times)...',
314 next_delay_sec, TRY_LIMIT - i - 1)
315 time.sleep(next_delay_sec)
316 next_delay_sec *= 2
317
318
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000319 @classmethod
320 def _get_token_dict(cls):
321 if cls._token_cache:
322 # If it expires within 25 seconds, refresh.
323 if cls._token_expiration < time.time() - 25:
324 return cls._token_cache
325
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100326 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000327 if resp.status != httplib.OK:
328 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100329 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000330 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
331 return cls._token_cache
332
333 def get_auth_header(self, _host):
334 token_dict = self._get_token_dict()
335 if not token_dict:
336 return None
337 return '%(token_type)s %(access_token)s' % token_dict
338
339
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700340class LuciContextAuthenticator(Authenticator):
341 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
342 """
343
344 @staticmethod
345 def is_luci():
346 return auth.has_luci_context_local_auth()
347
348 def __init__(self):
349 self._access_token = None
350 self._ensure_fresh()
351
352 def _ensure_fresh(self):
353 if not self._access_token or self._access_token.needs_refresh():
354 self._access_token = auth.get_luci_context_access_token(
355 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
356
357 def get_auth_header(self, _host):
358 self._ensure_fresh()
359 return 'Bearer %s' % self._access_token.token
360
361
szager@chromium.orgb4696232013-10-16 19:45:35 +0000362def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
363 """Opens an https connection to a gerrit service, and sends a request."""
364 headers = headers or {}
365 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000366
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700367 a = Authenticator.get().get_auth_header(bare_host)
368 if a:
369 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000370 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000371 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000372
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800373 url = path
374 if not url.startswith('/'):
375 url = '/' + url
376 if 'Authorization' in headers and not url.startswith('/a/'):
377 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000378
szager@chromium.orgb4696232013-10-16 19:45:35 +0000379 if body:
380 body = json.JSONEncoder().encode(body)
381 headers.setdefault('Content-Type', 'application/json')
382 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000383 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000384 for key, val in headers.iteritems():
385 if key == 'Authorization':
386 val = 'HIDDEN'
387 LOGGER.debug('%s: %s' % (key, val))
388 if body:
389 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700390 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000391 # HACK: httplib.Http has no such attribute; we store req_host here for later
392 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000393 conn.req_host = host
394 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100395 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000396 'method': reqtype,
397 'headers': headers,
398 'body': body,
399 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000400 return conn
401
402
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700403def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000404 """Reads an http response from a connection into a string buffer.
405
406 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100407 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700408 accept_statuses: Treat any of these statuses as success. Default: [200]
409 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000410 Returns: A string buffer containing the connection's reply.
411 """
Steve Kobes56117722018-09-13 18:18:35 +0000412 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000413 for idx in range(TRY_LIMIT):
Edward Lemur5a9ff432018-10-30 19:00:22 +0000414 before_response = time.time()
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100415 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000416
Edward Lemur5a9ff432018-10-30 19:00:22 +0000417 response_time = time.time() - before_response
418 metrics.collector.add_repeated(
419 'http_requests',
420 metrics_utils.extract_http_metrics(
421 conn.req_params['uri'], conn.req_params['method'], response.status,
422 response_time))
423
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000424 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100425 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000426 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
427 www_authenticate):
428 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
429 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700430 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000431 'has credentials for %s' % host)
432 raise GerritAuthenticationError(response.status, reason)
433
szager@chromium.orgb4696232013-10-16 19:45:35 +0000434 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000435 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700436 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000437 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700438 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100439 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100440 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700441 # If 404 was in accept_statuses, then it's expected that the file might
442 # not exist, so don't return the gitiles error page because that's not the
443 # "content" that was actually requested.
444 if response.status == 404:
445 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000446 break
Edward Lemur49c8eaf2018-11-07 22:13:12 +0000447 # A status >=500 is assumed to be a possible transient error; retry.
448 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
449 LOGGER.warn('A transient error occurred while querying %s:\n'
450 '%s %s %s\n'
451 '%s %d %s',
452 conn.req_host, conn.req_params['method'],
453 conn.req_params['uri'],
454 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000455 if response.status == 404:
456 # TODO(crbug/881860): remove this hack.
457 # HACK: try different Gerrit mirror as a workaround for potentially
458 # out-of-date mirror hit through default routing.
459 if conn.req_host == 'chromium-review.googlesource.com':
460 conn.req_params['uri'] = _UseGerritMirror(
461 conn.req_params['uri'], 'chromium-review.googlesource.com')
462 # And don't increase sleep_time in this case, since we suspect we've
463 # just asked wrong git mirror before.
464 sleep_time /= 2.0
465
szager@chromium.orgb4696232013-10-16 19:45:35 +0000466 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800467 LOGGER.info('Will retry in %d seconds (%d more times)...',
468 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000469 time.sleep(sleep_time)
470 sleep_time = sleep_time * 2
Edward Lemur83bd7f42018-10-10 00:14:21 +0000471 # end of retries loop
Aaron Gable19ee16c2017-04-18 11:56:35 -0700472 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200473 if response.status in (401, 403):
474 print('Your Gerrit credentials might be misconfigured. Try: \n'
475 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100476 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000477 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100478 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000479
480
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700481def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000482 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700483 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000484 # The first line of the response should always be: )]}'
485 s = fh.readline()
486 if s and s.rstrip() != ")]}'":
487 raise GerritError(200, 'Unexpected json output: %s' % s)
488 s = fh.read()
489 if not s:
490 return None
491 return json.loads(s)
492
493
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200494def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100495 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000496 """
497 Queries a gerrit-on-borg server for changes matching query terms.
498
499 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200500 params: A list of key:value pairs for search parameters, as documented
501 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
502 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000503 first_param: A change identifier
504 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100505 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000506 o_params: A list of additional output specifiers, as documented here:
507 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
508 Returns:
509 A list of json-decoded query results.
510 """
511 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200512 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000513 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200514 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100515 if start:
516 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000517 if limit:
518 path = '%s&n=%d' % (path, limit)
519 if o_params:
520 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700521 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000522
523
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200524def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100525 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000526 """
527 Queries a gerrit-on-borg server for all the changes matching the query terms.
528
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100529 WARNING: this is unreliable if a change matching the query is modified while
530 this function is being called.
531
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000532 A single query to gerrit-on-borg is limited on the number of results by the
533 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100534 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000535
536 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200537 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000538 limit: Maximum number of requested changes per query.
539 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100540 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000541
542 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100543 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000544 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100545 already_returned = set()
546 def at_most_once(cls):
547 for cl in cls:
548 if cl['_number'] not in already_returned:
549 already_returned.add(cl['_number'])
550 yield cl
551
552 start = start or 0
553 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000554 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100555
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000556 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100557 # This will fetch changes[start..start+limit] sorted by most recently
558 # updated. Since the rank of any change in this list can be changed any time
559 # (say user posting comment), subsequent calls may overalp like this:
560 # > initial order ABCDEFGH
561 # query[0..3] => ABC
562 # > E get's updated. New order: EABCDFGH
563 # query[3..6] => CDF # C is a dup
564 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200565 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100566 cur_start)
567 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000568 yield cl
569
570 more_changes = [cl for cl in page if '_more_changes' in cl]
571 if len(more_changes) > 1:
572 raise GerritError(
573 200,
574 'Received %d changes with a _more_changes attribute set but should '
575 'receive at most one.' % len(more_changes))
576 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100577 cur_start += len(page)
578
579 # If we paged through, query again the first page which in most circumstances
580 # will fetch all changes that were modified while this function was run.
581 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200582 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100583 for cl in at_most_once(page):
584 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000585
586
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200587def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100588 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000589 """Initiate a query composed of multiple sets of query parameters."""
590 if not change_list:
591 raise RuntimeError(
592 "MultiQueryChanges requires a list of change numbers/id's")
593 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200594 if params:
595 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000596 if limit:
597 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100598 if start:
599 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000600 if o_params:
601 q.extend(['o=%s' % p for p in o_params])
602 path = 'changes/?%s' % '&'.join(q)
603 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700604 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000605 except GerritError as e:
606 msg = '%s:\n%s' % (e.message, path)
607 raise GerritError(e.http_status, msg)
608 return result
609
610
611def GetGerritFetchUrl(host):
612 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
613 return '%s://%s/' % (GERRIT_PROTOCOL, host)
614
615
Edward Lemur687ca902018-12-05 02:30:30 +0000616def GetCodeReviewTbrScore(host, project):
617 """Given a gerrit host name and project, return the Code-Review score for TBR.
618 """
619 conn = CreateHttpConn(host, '/projects/%s' % urllib.quote(project, safe=''))
620 project = ReadHttpJsonResponse(conn)
621 if ('labels' not in project
622 or 'Code-Review' not in project['labels']
623 or 'values' not in project['labels']['Code-Review']):
624 return 1
625 return max([int(x) for x in project['labels']['Code-Review']['values']])
626
627
szager@chromium.orgb4696232013-10-16 19:45:35 +0000628def GetChangePageUrl(host, change_number):
629 """Given a gerrit host name and change number, return change page url."""
630 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
631
632
633def GetChangeUrl(host, change):
634 """Given a gerrit host name and change id, return an url for the change."""
635 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
636
637
638def GetChange(host, change):
639 """Query a gerrit server for information about a single change."""
640 path = 'changes/%s' % change
641 return ReadHttpJsonResponse(CreateHttpConn(host, path))
642
643
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700644def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000645 """Query a gerrit server for extended information about a single change."""
646 path = 'changes/%s/detail' % change
647 if o_params:
648 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700649 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000650
651
agable32978d92016-11-01 12:55:02 -0700652def GetChangeCommit(host, change, revision='current'):
653 """Query a gerrit server for a revision associated with a change."""
654 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
655 return ReadHttpJsonResponse(CreateHttpConn(host, path))
656
657
szager@chromium.orgb4696232013-10-16 19:45:35 +0000658def GetChangeCurrentRevision(host, change):
659 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200660 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000661
662
663def GetChangeRevisions(host, change):
664 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200665 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000666
667
668def GetChangeReview(host, change, revision=None):
669 """Get the current review information for a change."""
670 if not revision:
671 jmsg = GetChangeRevisions(host, change)
672 if not jmsg:
673 return None
674 elif len(jmsg) > 1:
675 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
676 revision = jmsg[0]['current_revision']
677 path = 'changes/%s/revisions/%s/review'
678 return ReadHttpJsonResponse(CreateHttpConn(host, path))
679
680
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700681def GetChangeComments(host, change):
682 """Get the line- and file-level comments on a change."""
683 path = 'changes/%s/comments' % change
684 return ReadHttpJsonResponse(CreateHttpConn(host, path))
685
686
Quinten Yearsley0e617c02019-02-20 00:37:03 +0000687def GetChangeRobotComments(host, change):
688 """Get the line- and file-level robot comments on a change."""
689 path = 'changes/%s/robotcomments' % change
690 return ReadHttpJsonResponse(CreateHttpConn(host, path))
691
692
szager@chromium.orgb4696232013-10-16 19:45:35 +0000693def AbandonChange(host, change, msg=''):
694 """Abandon a gerrit change."""
695 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000696 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000697 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700698 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000699
700
701def RestoreChange(host, change, msg=''):
702 """Restore a previously abandoned change."""
703 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000704 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000705 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700706 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000707
708
709def SubmitChange(host, change, wait_for_merge=True):
710 """Submits a gerrit change via Gerrit."""
711 path = 'changes/%s/submit' % change
712 body = {'wait_for_merge': wait_for_merge}
713 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700714 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000715
716
dsansomee2d6fd92016-09-08 00:10:47 -0700717def HasPendingChangeEdit(host, change):
718 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
719 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700720 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700721 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700722 # 204 No Content means no pending change.
723 if e.http_status == 204:
724 return False
725 raise
726 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700727
728
729def DeletePendingChangeEdit(host, change):
730 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700731 # On success, gerrit returns status 204; if the edit was already deleted it
732 # returns 404. Anything else is an error.
733 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700734
735
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100736def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000737 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700738 assert notify in ('ALL', 'NONE')
739 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700740 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700741 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000742 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700743 ReadHttpResponse(conn, accept_statuses=[200, 204])
744 except GerritError as e:
745 raise GerritError(
746 e.http_status,
747 'Received unexpected http status while editing message '
748 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000749
750
szager@chromium.orgb4696232013-10-16 19:45:35 +0000751def GetReviewers(host, change):
752 """Get information about all reviewers attached to a change."""
753 path = 'changes/%s/reviewers' % change
754 return ReadHttpJsonResponse(CreateHttpConn(host, path))
755
756
757def GetReview(host, change, revision):
758 """Get review information about a specific revision of a change."""
759 path = 'changes/%s/revisions/%s/review' % (change, revision)
760 return ReadHttpJsonResponse(CreateHttpConn(host, path))
761
762
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700763def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
764 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000765 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700766 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800767 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200768 if not change:
769 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700770 reviewers = frozenset(reviewers or [])
771 ccs = frozenset(ccs or [])
772 path = 'changes/%s/revisions/current/review' % change
773
774 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800775 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700776 'reviewers': [],
777 'notify': 'ALL' if notify else 'NONE',
778 }
779 for r in sorted(reviewers | ccs):
780 state = 'REVIEWER' if r in reviewers else 'CC'
781 body['reviewers'].append({
782 'reviewer': r,
783 'state': state,
784 'notify': 'NONE', # We handled `notify` argument above.
785 })
786
787 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
788 # Gerrit will return 400 if one or more of the requested reviewers are
789 # unprocessable. We read the response object to see which were rejected,
790 # warn about them, and retry with the remainder.
791 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
792
793 errored = set()
794 for result in resp.get('reviewers', {}).itervalues():
795 r = result.get('input')
796 state = 'REVIEWER' if r in reviewers else 'CC'
797 if result.get('error'):
798 errored.add(r)
799 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
800 if errored:
801 # Try again, adding only those that didn't fail, and only accepting 200.
802 AddReviewers(host, change, reviewers=(reviewers-errored),
803 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000804
805
806def RemoveReviewers(host, change, remove=None):
807 """Remove reveiewers from a change."""
808 if not remove:
809 return
810 if isinstance(remove, basestring):
811 remove = (remove,)
812 for r in remove:
813 path = 'changes/%s/reviewers/%s' % (change, r)
814 conn = CreateHttpConn(host, path, reqtype='DELETE')
815 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700816 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000817 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000818 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700819 e.http_status,
820 'Received unexpected http status while deleting reviewer "%s" '
821 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000822
823
Aaron Gable636b13f2017-07-14 10:42:48 -0700824def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000825 """Set labels and/or add a message to a code review."""
826 if not msg and not labels:
827 return
828 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800829 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000830 if msg:
831 body['message'] = msg
832 if labels:
833 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700834 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700835 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700836 if ready:
837 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000838 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
839 response = ReadHttpJsonResponse(conn)
840 if labels:
841 for key, val in labels.iteritems():
842 if ('labels' not in response or key not in response['labels'] or
843 int(response['labels'][key] != int(val))):
844 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
845 key, change))
846
847
848def ResetReviewLabels(host, change, label, value='0', message=None,
849 notify=None):
850 """Reset the value of a given label for all reviewers on a change."""
851 # This is tricky, because we want to work on the "current revision", but
852 # there's always the risk that "current revision" will change in between
853 # API calls. So, we check "current revision" at the beginning and end; if
854 # it has changed, raise an exception.
855 jmsg = GetChangeCurrentRevision(host, change)
856 if not jmsg:
857 raise GerritError(
858 200, 'Could not get review information for change "%s"' % change)
859 value = str(value)
860 revision = jmsg[0]['current_revision']
861 path = 'changes/%s/revisions/%s/review' % (change, revision)
862 message = message or (
863 '%s label set to %s programmatically.' % (label, value))
864 jmsg = GetReview(host, change, revision)
865 if not jmsg:
866 raise GerritError(200, 'Could not get review information for revison %s '
867 'of change %s' % (revision, change))
868 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
869 if str(review.get('value', value)) != value:
870 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800871 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000872 'message': message,
873 'labels': {label: value},
874 'on_behalf_of': review['_account_id'],
875 }
876 if notify:
877 body['notify'] = notify
878 conn = CreateHttpConn(
879 host, path, reqtype='POST', body=body)
880 response = ReadHttpJsonResponse(conn)
881 if str(response['labels'][label]) != value:
882 username = review.get('email', jmsg.get('name', ''))
883 raise GerritError(200, 'Unable to set %s label for user "%s"'
884 ' on change %s.' % (label, username, change))
885 jmsg = GetChangeCurrentRevision(host, change)
886 if not jmsg:
887 raise GerritError(
888 200, 'Could not get review information for change "%s"' % change)
889 elif jmsg[0]['current_revision'] != revision:
890 raise GerritError(200, 'While resetting labels on change "%s", '
891 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800892
893
dimu833c94c2017-01-18 17:36:15 -0800894def CreateGerritBranch(host, project, branch, commit):
895 """
896 Create a new branch from given project and commit
897 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
898
899 Returns:
900 A JSON with 'ref' key
901 """
902 path = 'projects/%s/branches/%s' % (project, branch)
903 body = {'revision': commit}
904 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700905 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800906 if response:
907 return response
908 raise GerritError(200, 'Unable to create gerrit branch')
909
910
911def GetGerritBranch(host, project, branch):
912 """
913 Get a branch from given project and commit
914 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
915
916 Returns:
917 A JSON object with 'revision' key
918 """
919 path = 'projects/%s/branches/%s' % (project, branch)
920 conn = CreateHttpConn(host, path, reqtype='GET')
921 response = ReadHttpJsonResponse(conn)
922 if response:
923 return response
924 raise GerritError(200, 'Unable to get gerrit branch')
925
926
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100927def GetAccountDetails(host, account_id='self'):
928 """Returns details of the account.
929
930 If account_id is not given, uses magic value 'self' which corresponds to
931 whichever account user is authenticating as.
932
933 Documentation:
934 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000935
936 Returns None if account is not found (i.e., Gerrit returned 404).
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100937 """
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100938 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
Andrii Shyshkalovba7b0a42018-10-15 03:20:35 +0000939 return ReadHttpJsonResponse(conn, accept_statuses=[200, 404])
940
941
942def ValidAccounts(host, accounts, max_threads=10):
943 """Returns a mapping from valid account to its details.
944
945 Invalid accounts, either not existing or without unique match,
946 are not present as returned dictionary keys.
947 """
948 assert not isinstance(accounts, basestring), type(accounts)
949 accounts = list(set(accounts))
950 if not accounts:
951 return {}
952 def get_one(account):
953 try:
954 return account, GetAccountDetails(host, account)
955 except GerritError:
956 return None, None
957 valid = {}
958 with contextlib.closing(ThreadPool(min(max_threads, len(accounts)))) as pool:
959 for account, details in pool.map(get_one, accounts):
960 if account and details:
961 valid[account] = details
962 return valid
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100963
964
Nick Carter8692b182017-11-06 16:30:38 -0800965def PercentEncodeForGitRef(original):
966 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
967
968 The encoding used is based on but stricter than URL encoding (Section 2.1
969 of RFC 3986). The only non-escaped characters are alphanumerics, and
970 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
971 'PLUS SIGN' (U+002B).
972
973 For more information, see the Gerrit docs here:
974
975 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
976 """
977 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
978 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
979
980 # spaces are not allowed in git refs; gerrit will interpret either '_' or
981 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
982 return encoded.replace(' ', '_')
983
984
Dan Jacques8d11e482016-11-15 14:25:56 -0800985@contextlib.contextmanager
986def tempdir():
987 tdir = None
988 try:
989 tdir = tempfile.mkdtemp(suffix='gerrit_util')
990 yield tdir
991 finally:
992 if tdir:
993 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000994
995
996def ChangeIdentifier(project, change_number):
Edward Lemur687ca902018-12-05 02:30:30 +0000997 """Returns change identifier "project~number" suitable for |change| arg of
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000998 this module API.
999
1000 Such format is allows for more efficient Gerrit routing of HTTP requests,
1001 comparing to specifying just change_number.
1002 """
1003 assert int(change_number)
1004 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001005
1006
1007# TODO(crbug/881860): remove this hack.
Andrii Shyshkalov94faf322018-10-12 21:35:38 +00001008_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3', 'eu1']
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +00001009assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
1010
1011
1012def _UseGerritMirror(url, host):
1013 """Returns new url which uses randomly selected mirror for a gerrit host.
1014
1015 url's host should be for a given host or a result of prior call to this
1016 function.
1017
1018 Assumes url has a single occurence of the host substring.
1019 """
1020 assert host in url
1021 suffix = '-mirror-' + host
1022 prefixes = set(_GERRIT_MIRROR_PREFIXES)
1023 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
1024 st = url.find(suffix)
1025 if st == -1:
1026 actual_host = host
1027 else:
1028 # Already uses some mirror.
1029 assert st >= prefix_len, (uri, host, st, prefix_len)
1030 prefixes.remove(url[st-prefix_len:st])
1031 actual_host = url[st-prefix_len:st+len(suffix)]
1032 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)