blob: b81f7b5cc6d25809e7651747125973f835a83fd2 [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
29
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070030import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080031import gclient_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080032import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010033from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000034
szager@chromium.orgb4696232013-10-16 19:45:35 +000035LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000036# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
37# total tries, the sleep time between the first and last tries will be 94.5 sec.
38# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
39TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000040
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000041
szager@chromium.orgb4696232013-10-16 19:45:35 +000042# Controls the transport protocol used to communicate with gerrit.
43# This is parameterized primarily to enable GerritTestCase.
44GERRIT_PROTOCOL = 'https'
45
46
47class GerritError(Exception):
48 """Exception class for errors commuicating with the gerrit-on-borg service."""
49 def __init__(self, http_status, *args, **kwargs):
50 super(GerritError, self).__init__(*args, **kwargs)
51 self.http_status = http_status
52 self.message = '(%d) %s' % (self.http_status, self.message)
53
54
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000055class GerritAuthenticationError(GerritError):
56 """Exception class for authentication errors during Gerrit communication."""
57
58
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020059def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000060 """Encodes query parameters in the key:val[+key:val...] format specified here:
61
62 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
63 """
64 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020065 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000066 return '+'.join(q)
67
68
Aaron Gabled2db5a22017-03-24 14:14:15 -070069def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000070 if protocol is None:
71 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010072 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070073 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000074 else:
75 raise RuntimeError(
76 "Don't know how to work with protocol '%s'" % protocol)
77
78
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000079class Authenticator(object):
80 """Base authenticator class for authenticator implementations to subclass."""
81
82 def get_auth_header(self, host):
83 raise NotImplementedError()
84
85 @staticmethod
86 def get():
87 """Returns: (Authenticator) The identified Authenticator to use.
88
89 Probes the local system and its environment and identifies the
90 Authenticator instance to use.
91 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070092 # LUCI Context takes priority since it's normally present only on bots,
93 # which then must use it.
94 if LuciContextAuthenticator.is_luci():
95 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000096 if GceAuthenticator.is_gce():
97 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000098 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000099
100
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000101class CookiesAuthenticator(Authenticator):
102 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
103
104 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000105 """
106
107 def __init__(self):
108 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000109 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000110
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000111 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200112 def get_new_password_url(cls, host):
113 assert not host.startswith('http')
114 # Assume *.googlesource.com pattern.
115 parts = host.split('.')
116 if not parts[0].endswith('-review'):
117 parts[0] += '-review'
118 return 'https://%s/new-password' % ('.'.join(parts))
119
120 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000121 def get_new_password_message(cls, host):
122 assert not host.startswith('http')
123 # Assume *.googlesource.com pattern.
124 parts = host.split('.')
125 if not parts[0].endswith('-review'):
126 parts[0] += '-review'
127 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100128 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000129
130 @classmethod
131 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000132 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000133 return os.path.expanduser(os.path.join('~', path))
134
135 @classmethod
136 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800137 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000138 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000139 if not os.path.exists(path):
140 return netrc.netrc(os.devnull)
141
142 st = os.stat(path)
143 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
144 print >> sys.stderr, (
145 'WARNING: netrc file %s cannot be used because its file '
146 'permissions are insecure. netrc file permissions should be '
147 '600.' % path)
148 with open(path) as fd:
149 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800150
151 # Load the '.netrc' file. We strip comments from it because processing them
152 # can trigger a bug in Windows. See crbug.com/664664.
153 content = '\n'.join(l for l in content.splitlines()
154 if l.strip() and not l.strip().startswith('#'))
155 with tempdir() as tdir:
156 netrc_path = os.path.join(tdir, 'netrc')
157 with open(netrc_path, 'w') as fd:
158 fd.write(content)
159 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
160 return cls._get_netrc_from_path(netrc_path)
161
162 @classmethod
163 def _get_netrc_from_path(cls, path):
164 try:
165 return netrc.netrc(path)
166 except IOError:
167 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
168 return netrc.netrc(os.devnull)
169 except netrc.NetrcParseError as e:
170 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
171 'parsing error: %s' % (path, e))
172 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000173
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000174 @classmethod
175 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500176 if os.getenv('GIT_COOKIES_PATH'):
177 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800178 try:
179 return subprocess2.check_output(
180 ['git', 'config', '--path', 'http.cookiefile']).strip()
181 except subprocess2.CalledProcessError:
182 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000183
184 @classmethod
185 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000186 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000187 path = cls.get_gitcookies_path()
188 if not os.path.exists(path):
189 return gitcookies
190
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000191 try:
192 f = open(path, 'rb')
193 except IOError:
194 return gitcookies
195
196 with f:
197 for line in f:
198 try:
199 fields = line.strip().split('\t')
200 if line.strip().startswith('#') or len(fields) != 7:
201 continue
202 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
203 if xpath == '/' and key == 'o':
Eric Boren05263352018-09-18 16:54:45 +0000204 login, secret_token = value.split('=', 1)
205 gitcookies[domain] = (login, secret_token)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000206 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100207 LOGGER.warning(exc)
Eric Boren05263352018-09-18 16:54:45 +0000208
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000209 return gitcookies
210
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100211 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000212 for domain, creds in self.gitcookies.iteritems():
213 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100214 return (creds[0], None, creds[1])
215 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000216
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100217 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700218 a = self._get_auth_for_host(host)
219 if a:
Eric Boren05263352018-09-18 16:54:45 +0000220 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000221 return None
222
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100223 def get_auth_email(self, host):
224 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700225 a = self._get_auth_for_host(host)
226 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100227 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700228 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100229 # login typically looks like 'git-xxx.example.com'
230 if not login.startswith('git-') or '.' not in login:
231 return None
232 username, domain = login[len('git-'):].split('.', 1)
233 return '%s@%s' % (username, domain)
234
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100235
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000236# Backwards compatibility just in case somebody imports this outside of
237# depot_tools.
238NetrcAuthenticator = CookiesAuthenticator
239
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000240
241class GceAuthenticator(Authenticator):
242 """Authenticator implementation that uses GCE metadata service for token.
243 """
244
245 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700246 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
247 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000248 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
249
250 _cache_is_gce = None
251 _token_cache = None
252 _token_expiration = None
253
254 @classmethod
255 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500256 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
257 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000258 if cls._cache_is_gce is None:
259 cls._cache_is_gce = cls._test_is_gce()
260 return cls._cache_is_gce
261
262 @classmethod
263 def _test_is_gce(cls):
264 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
265 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100266 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100267 except (socket.error, httplib2.ServerNotFoundError,
268 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000269 # Could not resolve URL.
270 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100271 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000272
273 @staticmethod
274 def _get(url, **kwargs):
275 next_delay_sec = 1
276 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000277 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700278 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100279 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000280 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
281 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100282 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000283
Aaron Gable92e9f382017-12-07 11:47:41 -0800284 # Retry server error status codes.
285 LOGGER.warn('Encountered server error')
286 if TRY_LIMIT - i > 1:
287 LOGGER.info('Will retry in %d seconds (%d more times)...',
288 next_delay_sec, TRY_LIMIT - i - 1)
289 time.sleep(next_delay_sec)
290 next_delay_sec *= 2
291
292
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000293 @classmethod
294 def _get_token_dict(cls):
295 if cls._token_cache:
296 # If it expires within 25 seconds, refresh.
297 if cls._token_expiration < time.time() - 25:
298 return cls._token_cache
299
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100300 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000301 if resp.status != httplib.OK:
302 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100303 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000304 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
305 return cls._token_cache
306
307 def get_auth_header(self, _host):
308 token_dict = self._get_token_dict()
309 if not token_dict:
310 return None
311 return '%(token_type)s %(access_token)s' % token_dict
312
313
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700314class LuciContextAuthenticator(Authenticator):
315 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
316 """
317
318 @staticmethod
319 def is_luci():
320 return auth.has_luci_context_local_auth()
321
322 def __init__(self):
323 self._access_token = None
324 self._ensure_fresh()
325
326 def _ensure_fresh(self):
327 if not self._access_token or self._access_token.needs_refresh():
328 self._access_token = auth.get_luci_context_access_token(
329 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
330
331 def get_auth_header(self, _host):
332 self._ensure_fresh()
333 return 'Bearer %s' % self._access_token.token
334
335
szager@chromium.orgb4696232013-10-16 19:45:35 +0000336def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
337 """Opens an https connection to a gerrit service, and sends a request."""
338 headers = headers or {}
339 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000340
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700341 a = Authenticator.get().get_auth_header(bare_host)
342 if a:
343 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000344 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000345 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000346
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800347 url = path
348 if not url.startswith('/'):
349 url = '/' + url
350 if 'Authorization' in headers and not url.startswith('/a/'):
351 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000352
szager@chromium.orgb4696232013-10-16 19:45:35 +0000353 if body:
354 body = json.JSONEncoder().encode(body)
355 headers.setdefault('Content-Type', 'application/json')
356 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000357 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000358 for key, val in headers.iteritems():
359 if key == 'Authorization':
360 val = 'HIDDEN'
361 LOGGER.debug('%s: %s' % (key, val))
362 if body:
363 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700364 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000365 # HACK: httplib.Http has no such attribute; we store req_host here for later
366 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000367 conn.req_host = host
368 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100369 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000370 'method': reqtype,
371 'headers': headers,
372 'body': body,
373 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000374 return conn
375
376
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700377def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000378 """Reads an http response from a connection into a string buffer.
379
380 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100381 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700382 accept_statuses: Treat any of these statuses as success. Default: [200]
383 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000384 Returns: A string buffer containing the connection's reply.
385 """
Steve Kobes56117722018-09-13 18:18:35 +0000386 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000387 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100388 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000389
390 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100391 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000392 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
393 www_authenticate):
394 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
395 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700396 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000397 'has credentials for %s' % host)
398 raise GerritAuthenticationError(response.status, reason)
399
szager@chromium.orgb4696232013-10-16 19:45:35 +0000400 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000401 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700402 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000403 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700404 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100405 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100406 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700407 # If 404 was in accept_statuses, then it's expected that the file might
408 # not exist, so don't return the gitiles error page because that's not the
409 # "content" that was actually requested.
410 if response.status == 404:
411 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000412 break
413 # A status >=500 is assumed to be a possible transient error; retry.
414 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andy Perelsona06cd092018-09-24 21:29:57 +0000415 LOGGER.warn('A transient error occurred while querying %s:\n'
416 '%s %s %s\n'
417 '%s %d %s',
418 conn.req_host, conn.req_params['method'],
419 conn.req_params['uri'],
420 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000421 if response.status == 404:
422 # TODO(crbug/881860): remove this hack.
423 # HACK: try different Gerrit mirror as a workaround for potentially
424 # out-of-date mirror hit through default routing.
425 if conn.req_host == 'chromium-review.googlesource.com':
426 conn.req_params['uri'] = _UseGerritMirror(
427 conn.req_params['uri'], 'chromium-review.googlesource.com')
428 # And don't increase sleep_time in this case, since we suspect we've
429 # just asked wrong git mirror before.
430 sleep_time /= 2.0
431
szager@chromium.orgb4696232013-10-16 19:45:35 +0000432 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800433 LOGGER.info('Will retry in %d seconds (%d more times)...',
434 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000435 time.sleep(sleep_time)
436 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700437 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200438 if response.status in (401, 403):
439 print('Your Gerrit credentials might be misconfigured. Try: \n'
440 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100441 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000442 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100443 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000444
445
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700446def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000447 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700448 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000449 # The first line of the response should always be: )]}'
450 s = fh.readline()
451 if s and s.rstrip() != ")]}'":
452 raise GerritError(200, 'Unexpected json output: %s' % s)
453 s = fh.read()
454 if not s:
455 return None
456 return json.loads(s)
457
458
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200459def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100460 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000461 """
462 Queries a gerrit-on-borg server for changes matching query terms.
463
464 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200465 params: A list of key:value pairs for search parameters, as documented
466 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
467 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000468 first_param: A change identifier
469 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100470 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000471 o_params: A list of additional output specifiers, as documented here:
472 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
473 Returns:
474 A list of json-decoded query results.
475 """
476 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200477 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000478 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200479 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100480 if start:
481 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000482 if limit:
483 path = '%s&n=%d' % (path, limit)
484 if o_params:
485 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700486 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000487
488
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200489def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100490 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000491 """
492 Queries a gerrit-on-borg server for all the changes matching the query terms.
493
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100494 WARNING: this is unreliable if a change matching the query is modified while
495 this function is being called.
496
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000497 A single query to gerrit-on-borg is limited on the number of results by the
498 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000500
501 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200502 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000503 limit: Maximum number of requested changes per query.
504 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100505 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000506
507 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100508 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000509 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 already_returned = set()
511 def at_most_once(cls):
512 for cl in cls:
513 if cl['_number'] not in already_returned:
514 already_returned.add(cl['_number'])
515 yield cl
516
517 start = start or 0
518 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000519 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100520
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000521 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100522 # This will fetch changes[start..start+limit] sorted by most recently
523 # updated. Since the rank of any change in this list can be changed any time
524 # (say user posting comment), subsequent calls may overalp like this:
525 # > initial order ABCDEFGH
526 # query[0..3] => ABC
527 # > E get's updated. New order: EABCDFGH
528 # query[3..6] => CDF # C is a dup
529 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200530 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 cur_start)
532 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000533 yield cl
534
535 more_changes = [cl for cl in page if '_more_changes' in cl]
536 if len(more_changes) > 1:
537 raise GerritError(
538 200,
539 'Received %d changes with a _more_changes attribute set but should '
540 'receive at most one.' % len(more_changes))
541 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100542 cur_start += len(page)
543
544 # If we paged through, query again the first page which in most circumstances
545 # will fetch all changes that were modified while this function was run.
546 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200547 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100548 for cl in at_most_once(page):
549 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000550
551
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200552def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100553 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000554 """Initiate a query composed of multiple sets of query parameters."""
555 if not change_list:
556 raise RuntimeError(
557 "MultiQueryChanges requires a list of change numbers/id's")
558 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200559 if params:
560 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000561 if limit:
562 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100563 if start:
564 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000565 if o_params:
566 q.extend(['o=%s' % p for p in o_params])
567 path = 'changes/?%s' % '&'.join(q)
568 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700569 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000570 except GerritError as e:
571 msg = '%s:\n%s' % (e.message, path)
572 raise GerritError(e.http_status, msg)
573 return result
574
575
576def GetGerritFetchUrl(host):
577 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
578 return '%s://%s/' % (GERRIT_PROTOCOL, host)
579
580
581def GetChangePageUrl(host, change_number):
582 """Given a gerrit host name and change number, return change page url."""
583 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
584
585
586def GetChangeUrl(host, change):
587 """Given a gerrit host name and change id, return an url for the change."""
588 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
589
590
591def GetChange(host, change):
592 """Query a gerrit server for information about a single change."""
593 path = 'changes/%s' % change
594 return ReadHttpJsonResponse(CreateHttpConn(host, path))
595
596
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700597def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000598 """Query a gerrit server for extended information about a single change."""
599 path = 'changes/%s/detail' % change
600 if o_params:
601 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700602 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000603
604
agable32978d92016-11-01 12:55:02 -0700605def GetChangeCommit(host, change, revision='current'):
606 """Query a gerrit server for a revision associated with a change."""
607 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
608 return ReadHttpJsonResponse(CreateHttpConn(host, path))
609
610
szager@chromium.orgb4696232013-10-16 19:45:35 +0000611def GetChangeCurrentRevision(host, change):
612 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200613 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000614
615
616def GetChangeRevisions(host, change):
617 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200618 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000619
620
621def GetChangeReview(host, change, revision=None):
622 """Get the current review information for a change."""
623 if not revision:
624 jmsg = GetChangeRevisions(host, change)
625 if not jmsg:
626 return None
627 elif len(jmsg) > 1:
628 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
629 revision = jmsg[0]['current_revision']
630 path = 'changes/%s/revisions/%s/review'
631 return ReadHttpJsonResponse(CreateHttpConn(host, path))
632
633
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700634def GetChangeComments(host, change):
635 """Get the line- and file-level comments on a change."""
636 path = 'changes/%s/comments' % change
637 return ReadHttpJsonResponse(CreateHttpConn(host, path))
638
639
szager@chromium.orgb4696232013-10-16 19:45:35 +0000640def AbandonChange(host, change, msg=''):
641 """Abandon a gerrit change."""
642 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000643 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000644 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700645 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000646
647
648def RestoreChange(host, change, msg=''):
649 """Restore a previously abandoned change."""
650 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000651 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000652 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700653 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000654
655
656def SubmitChange(host, change, wait_for_merge=True):
657 """Submits a gerrit change via Gerrit."""
658 path = 'changes/%s/submit' % change
659 body = {'wait_for_merge': wait_for_merge}
660 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700661 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000662
663
dsansomee2d6fd92016-09-08 00:10:47 -0700664def HasPendingChangeEdit(host, change):
665 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
666 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700667 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700668 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700669 # 204 No Content means no pending change.
670 if e.http_status == 204:
671 return False
672 raise
673 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700674
675
676def DeletePendingChangeEdit(host, change):
677 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700678 # On success, gerrit returns status 204; if the edit was already deleted it
679 # returns 404. Anything else is an error.
680 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700681
682
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100683def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000684 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700685 assert notify in ('ALL', 'NONE')
686 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700687 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700688 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000689 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700690 ReadHttpResponse(conn, accept_statuses=[200, 204])
691 except GerritError as e:
692 raise GerritError(
693 e.http_status,
694 'Received unexpected http status while editing message '
695 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000696
697
szager@chromium.orgb4696232013-10-16 19:45:35 +0000698def GetReviewers(host, change):
699 """Get information about all reviewers attached to a change."""
700 path = 'changes/%s/reviewers' % change
701 return ReadHttpJsonResponse(CreateHttpConn(host, path))
702
703
704def GetReview(host, change, revision):
705 """Get review information about a specific revision of a change."""
706 path = 'changes/%s/revisions/%s/review' % (change, revision)
707 return ReadHttpJsonResponse(CreateHttpConn(host, path))
708
709
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700710def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
711 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000712 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700713 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800714 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200715 if not change:
716 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700717 reviewers = frozenset(reviewers or [])
718 ccs = frozenset(ccs or [])
719 path = 'changes/%s/revisions/current/review' % change
720
721 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800722 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700723 'reviewers': [],
724 'notify': 'ALL' if notify else 'NONE',
725 }
726 for r in sorted(reviewers | ccs):
727 state = 'REVIEWER' if r in reviewers else 'CC'
728 body['reviewers'].append({
729 'reviewer': r,
730 'state': state,
731 'notify': 'NONE', # We handled `notify` argument above.
732 })
733
734 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
735 # Gerrit will return 400 if one or more of the requested reviewers are
736 # unprocessable. We read the response object to see which were rejected,
737 # warn about them, and retry with the remainder.
738 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
739
740 errored = set()
741 for result in resp.get('reviewers', {}).itervalues():
742 r = result.get('input')
743 state = 'REVIEWER' if r in reviewers else 'CC'
744 if result.get('error'):
745 errored.add(r)
746 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
747 if errored:
748 # Try again, adding only those that didn't fail, and only accepting 200.
749 AddReviewers(host, change, reviewers=(reviewers-errored),
750 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000751
752
753def RemoveReviewers(host, change, remove=None):
754 """Remove reveiewers from a change."""
755 if not remove:
756 return
757 if isinstance(remove, basestring):
758 remove = (remove,)
759 for r in remove:
760 path = 'changes/%s/reviewers/%s' % (change, r)
761 conn = CreateHttpConn(host, path, reqtype='DELETE')
762 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700763 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000764 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000765 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700766 e.http_status,
767 'Received unexpected http status while deleting reviewer "%s" '
768 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000769
770
Aaron Gable636b13f2017-07-14 10:42:48 -0700771def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000772 """Set labels and/or add a message to a code review."""
773 if not msg and not labels:
774 return
775 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800776 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000777 if msg:
778 body['message'] = msg
779 if labels:
780 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700781 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700782 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700783 if ready:
784 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000785 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
786 response = ReadHttpJsonResponse(conn)
787 if labels:
788 for key, val in labels.iteritems():
789 if ('labels' not in response or key not in response['labels'] or
790 int(response['labels'][key] != int(val))):
791 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
792 key, change))
793
794
795def ResetReviewLabels(host, change, label, value='0', message=None,
796 notify=None):
797 """Reset the value of a given label for all reviewers on a change."""
798 # This is tricky, because we want to work on the "current revision", but
799 # there's always the risk that "current revision" will change in between
800 # API calls. So, we check "current revision" at the beginning and end; if
801 # it has changed, raise an exception.
802 jmsg = GetChangeCurrentRevision(host, change)
803 if not jmsg:
804 raise GerritError(
805 200, 'Could not get review information for change "%s"' % change)
806 value = str(value)
807 revision = jmsg[0]['current_revision']
808 path = 'changes/%s/revisions/%s/review' % (change, revision)
809 message = message or (
810 '%s label set to %s programmatically.' % (label, value))
811 jmsg = GetReview(host, change, revision)
812 if not jmsg:
813 raise GerritError(200, 'Could not get review information for revison %s '
814 'of change %s' % (revision, change))
815 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
816 if str(review.get('value', value)) != value:
817 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800818 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000819 'message': message,
820 'labels': {label: value},
821 'on_behalf_of': review['_account_id'],
822 }
823 if notify:
824 body['notify'] = notify
825 conn = CreateHttpConn(
826 host, path, reqtype='POST', body=body)
827 response = ReadHttpJsonResponse(conn)
828 if str(response['labels'][label]) != value:
829 username = review.get('email', jmsg.get('name', ''))
830 raise GerritError(200, 'Unable to set %s label for user "%s"'
831 ' on change %s.' % (label, username, change))
832 jmsg = GetChangeCurrentRevision(host, change)
833 if not jmsg:
834 raise GerritError(
835 200, 'Could not get review information for change "%s"' % change)
836 elif jmsg[0]['current_revision'] != revision:
837 raise GerritError(200, 'While resetting labels on change "%s", '
838 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800839
840
dimu833c94c2017-01-18 17:36:15 -0800841def CreateGerritBranch(host, project, branch, commit):
842 """
843 Create a new branch from given project and commit
844 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
845
846 Returns:
847 A JSON with 'ref' key
848 """
849 path = 'projects/%s/branches/%s' % (project, branch)
850 body = {'revision': commit}
851 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700852 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800853 if response:
854 return response
855 raise GerritError(200, 'Unable to create gerrit branch')
856
857
858def GetGerritBranch(host, project, branch):
859 """
860 Get a branch from given project and commit
861 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
862
863 Returns:
864 A JSON object with 'revision' key
865 """
866 path = 'projects/%s/branches/%s' % (project, branch)
867 conn = CreateHttpConn(host, path, reqtype='GET')
868 response = ReadHttpJsonResponse(conn)
869 if response:
870 return response
871 raise GerritError(200, 'Unable to get gerrit branch')
872
873
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100874def GetAccountDetails(host, account_id='self'):
875 """Returns details of the account.
876
877 If account_id is not given, uses magic value 'self' which corresponds to
878 whichever account user is authenticating as.
879
880 Documentation:
881 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
882 """
883 if account_id != 'self':
884 account_id = int(account_id)
885 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
886 return ReadHttpJsonResponse(conn)
887
888
Nick Carter8692b182017-11-06 16:30:38 -0800889def PercentEncodeForGitRef(original):
890 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
891
892 The encoding used is based on but stricter than URL encoding (Section 2.1
893 of RFC 3986). The only non-escaped characters are alphanumerics, and
894 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
895 'PLUS SIGN' (U+002B).
896
897 For more information, see the Gerrit docs here:
898
899 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
900 """
901 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
902 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
903
904 # spaces are not allowed in git refs; gerrit will interpret either '_' or
905 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
906 return encoded.replace(' ', '_')
907
908
Dan Jacques8d11e482016-11-15 14:25:56 -0800909@contextlib.contextmanager
910def tempdir():
911 tdir = None
912 try:
913 tdir = tempfile.mkdtemp(suffix='gerrit_util')
914 yield tdir
915 finally:
916 if tdir:
917 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000918
919
920def ChangeIdentifier(project, change_number):
921 """Returns change identifier "project~number" suitable for |chagne| arg of
922 this module API.
923
924 Such format is allows for more efficient Gerrit routing of HTTP requests,
925 comparing to specifying just change_number.
926 """
927 assert int(change_number)
928 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000929
930
931# TODO(crbug/881860): remove this hack.
932_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3']
933assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
934
935
936def _UseGerritMirror(url, host):
937 """Returns new url which uses randomly selected mirror for a gerrit host.
938
939 url's host should be for a given host or a result of prior call to this
940 function.
941
942 Assumes url has a single occurence of the host substring.
943 """
944 assert host in url
945 suffix = '-mirror-' + host
946 prefixes = set(_GERRIT_MIRROR_PREFIXES)
947 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
948 st = url.find(suffix)
949 if st == -1:
950 actual_host = host
951 else:
952 # Already uses some mirror.
953 assert st >= prefix_len, (uri, host, st, prefix_len)
954 prefixes.remove(url[st-prefix_len:st])
955 actual_host = url[st-prefix_len:st+len(suffix)]
956 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)