blob: e8e95610ea0873d5ea1b4c2a7c6c4b6115e985b2 [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
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000107 _EMPTY = object()
108
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000109 def __init__(self):
Vadim Shtayurab250ec12018-10-04 00:21:08 +0000110 # Credentials will be loaded lazily on first use. This ensures Authenticator
111 # get() can always construct an authenticator, even if something is broken.
112 # This allows 'creds-check' to proceed to actually checking creds later,
113 # rigorously (instead of blowing up with a cryptic error if they are wrong).
114 self._netrc = self._EMPTY
115 self._gitcookies = self._EMPTY
116
117 @property
118 def netrc(self):
119 if self._netrc is self._EMPTY:
120 self._netrc = self._get_netrc()
121 return self._netrc
122
123 @property
124 def gitcookies(self):
125 if self._gitcookies is self._EMPTY:
126 self._gitcookies = self._get_gitcookies()
127 return self._gitcookies
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000128
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000129 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200130 def get_new_password_url(cls, host):
131 assert not host.startswith('http')
132 # Assume *.googlesource.com pattern.
133 parts = host.split('.')
134 if not parts[0].endswith('-review'):
135 parts[0] += '-review'
136 return 'https://%s/new-password' % ('.'.join(parts))
137
138 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000139 def get_new_password_message(cls, host):
140 assert not host.startswith('http')
141 # Assume *.googlesource.com pattern.
142 parts = host.split('.')
143 if not parts[0].endswith('-review'):
144 parts[0] += '-review'
145 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100146 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000147
148 @classmethod
149 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000150 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000151 return os.path.expanduser(os.path.join('~', path))
152
153 @classmethod
154 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800155 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000156 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000157 if not os.path.exists(path):
158 return netrc.netrc(os.devnull)
159
160 st = os.stat(path)
161 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
162 print >> sys.stderr, (
163 'WARNING: netrc file %s cannot be used because its file '
164 'permissions are insecure. netrc file permissions should be '
165 '600.' % path)
166 with open(path) as fd:
167 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800168
169 # Load the '.netrc' file. We strip comments from it because processing them
170 # can trigger a bug in Windows. See crbug.com/664664.
171 content = '\n'.join(l for l in content.splitlines()
172 if l.strip() and not l.strip().startswith('#'))
173 with tempdir() as tdir:
174 netrc_path = os.path.join(tdir, 'netrc')
175 with open(netrc_path, 'w') as fd:
176 fd.write(content)
177 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
178 return cls._get_netrc_from_path(netrc_path)
179
180 @classmethod
181 def _get_netrc_from_path(cls, path):
182 try:
183 return netrc.netrc(path)
184 except IOError:
185 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
186 return netrc.netrc(os.devnull)
187 except netrc.NetrcParseError as e:
188 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
189 'parsing error: %s' % (path, e))
190 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000191
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000192 @classmethod
193 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500194 if os.getenv('GIT_COOKIES_PATH'):
195 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800196 try:
197 return subprocess2.check_output(
198 ['git', 'config', '--path', 'http.cookiefile']).strip()
199 except subprocess2.CalledProcessError:
200 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000201
202 @classmethod
203 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000204 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000205 path = cls.get_gitcookies_path()
206 if not os.path.exists(path):
207 return gitcookies
208
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000209 try:
210 f = open(path, 'rb')
211 except IOError:
212 return gitcookies
213
214 with f:
215 for line in f:
216 try:
217 fields = line.strip().split('\t')
218 if line.strip().startswith('#') or len(fields) != 7:
219 continue
220 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
221 if xpath == '/' and key == 'o':
Eric Boren05263352018-09-18 16:54:45 +0000222 login, secret_token = value.split('=', 1)
223 gitcookies[domain] = (login, secret_token)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000224 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100225 LOGGER.warning(exc)
Eric Boren05263352018-09-18 16:54:45 +0000226
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000227 return gitcookies
228
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100229 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000230 for domain, creds in self.gitcookies.iteritems():
231 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100232 return (creds[0], None, creds[1])
233 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000234
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100235 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700236 a = self._get_auth_for_host(host)
237 if a:
Eric Boren05263352018-09-18 16:54:45 +0000238 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000239 return None
240
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100241 def get_auth_email(self, host):
242 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700243 a = self._get_auth_for_host(host)
244 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100245 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700246 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100247 # login typically looks like 'git-xxx.example.com'
248 if not login.startswith('git-') or '.' not in login:
249 return None
250 username, domain = login[len('git-'):].split('.', 1)
251 return '%s@%s' % (username, domain)
252
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100253
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000254# Backwards compatibility just in case somebody imports this outside of
255# depot_tools.
256NetrcAuthenticator = CookiesAuthenticator
257
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000258
259class GceAuthenticator(Authenticator):
260 """Authenticator implementation that uses GCE metadata service for token.
261 """
262
263 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700264 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
265 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000266 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
267
268 _cache_is_gce = None
269 _token_cache = None
270 _token_expiration = None
271
272 @classmethod
273 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500274 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
275 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000276 if cls._cache_is_gce is None:
277 cls._cache_is_gce = cls._test_is_gce()
278 return cls._cache_is_gce
279
280 @classmethod
281 def _test_is_gce(cls):
282 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
283 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100284 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100285 except (socket.error, httplib2.ServerNotFoundError,
286 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000287 # Could not resolve URL.
288 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100289 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000290
291 @staticmethod
292 def _get(url, **kwargs):
293 next_delay_sec = 1
294 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000295 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700296 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100297 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000298 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
299 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100300 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000301
Aaron Gable92e9f382017-12-07 11:47:41 -0800302 # Retry server error status codes.
303 LOGGER.warn('Encountered server error')
304 if TRY_LIMIT - i > 1:
305 LOGGER.info('Will retry in %d seconds (%d more times)...',
306 next_delay_sec, TRY_LIMIT - i - 1)
307 time.sleep(next_delay_sec)
308 next_delay_sec *= 2
309
310
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000311 @classmethod
312 def _get_token_dict(cls):
313 if cls._token_cache:
314 # If it expires within 25 seconds, refresh.
315 if cls._token_expiration < time.time() - 25:
316 return cls._token_cache
317
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100318 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000319 if resp.status != httplib.OK:
320 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100321 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000322 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
323 return cls._token_cache
324
325 def get_auth_header(self, _host):
326 token_dict = self._get_token_dict()
327 if not token_dict:
328 return None
329 return '%(token_type)s %(access_token)s' % token_dict
330
331
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700332class LuciContextAuthenticator(Authenticator):
333 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
334 """
335
336 @staticmethod
337 def is_luci():
338 return auth.has_luci_context_local_auth()
339
340 def __init__(self):
341 self._access_token = None
342 self._ensure_fresh()
343
344 def _ensure_fresh(self):
345 if not self._access_token or self._access_token.needs_refresh():
346 self._access_token = auth.get_luci_context_access_token(
347 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
348
349 def get_auth_header(self, _host):
350 self._ensure_fresh()
351 return 'Bearer %s' % self._access_token.token
352
353
szager@chromium.orgb4696232013-10-16 19:45:35 +0000354def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
355 """Opens an https connection to a gerrit service, and sends a request."""
356 headers = headers or {}
357 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000358
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700359 a = Authenticator.get().get_auth_header(bare_host)
360 if a:
361 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000362 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000363 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000364
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800365 url = path
366 if not url.startswith('/'):
367 url = '/' + url
368 if 'Authorization' in headers and not url.startswith('/a/'):
369 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000370
szager@chromium.orgb4696232013-10-16 19:45:35 +0000371 if body:
372 body = json.JSONEncoder().encode(body)
373 headers.setdefault('Content-Type', 'application/json')
374 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000375 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000376 for key, val in headers.iteritems():
377 if key == 'Authorization':
378 val = 'HIDDEN'
379 LOGGER.debug('%s: %s' % (key, val))
380 if body:
381 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700382 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000383 # HACK: httplib.Http has no such attribute; we store req_host here for later
384 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000385 conn.req_host = host
386 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100387 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000388 'method': reqtype,
389 'headers': headers,
390 'body': body,
391 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000392 return conn
393
394
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700395def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000396 """Reads an http response from a connection into a string buffer.
397
398 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100399 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700400 accept_statuses: Treat any of these statuses as success. Default: [200]
401 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000402 Returns: A string buffer containing the connection's reply.
403 """
Steve Kobes56117722018-09-13 18:18:35 +0000404 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000405 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100406 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000407
408 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100409 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000410 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
411 www_authenticate):
412 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
413 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700414 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000415 'has credentials for %s' % host)
416 raise GerritAuthenticationError(response.status, reason)
417
szager@chromium.orgb4696232013-10-16 19:45:35 +0000418 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000419 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700420 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000421 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700422 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100423 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100424 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700425 # If 404 was in accept_statuses, then it's expected that the file might
426 # not exist, so don't return the gitiles error page because that's not the
427 # "content" that was actually requested.
428 if response.status == 404:
429 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000430 break
431 # A status >=500 is assumed to be a possible transient error; retry.
432 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andy Perelsona06cd092018-09-24 21:29:57 +0000433 LOGGER.warn('A transient error occurred while querying %s:\n'
434 '%s %s %s\n'
435 '%s %d %s',
436 conn.req_host, conn.req_params['method'],
437 conn.req_params['uri'],
438 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000439 if response.status == 404:
440 # TODO(crbug/881860): remove this hack.
441 # HACK: try different Gerrit mirror as a workaround for potentially
442 # out-of-date mirror hit through default routing.
443 if conn.req_host == 'chromium-review.googlesource.com':
444 conn.req_params['uri'] = _UseGerritMirror(
445 conn.req_params['uri'], 'chromium-review.googlesource.com')
446 # And don't increase sleep_time in this case, since we suspect we've
447 # just asked wrong git mirror before.
448 sleep_time /= 2.0
449
szager@chromium.orgb4696232013-10-16 19:45:35 +0000450 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800451 LOGGER.info('Will retry in %d seconds (%d more times)...',
452 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000453 time.sleep(sleep_time)
454 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700455 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200456 if response.status in (401, 403):
457 print('Your Gerrit credentials might be misconfigured. Try: \n'
458 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100459 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000460 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100461 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000462
463
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700464def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000465 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700466 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000467 # The first line of the response should always be: )]}'
468 s = fh.readline()
469 if s and s.rstrip() != ")]}'":
470 raise GerritError(200, 'Unexpected json output: %s' % s)
471 s = fh.read()
472 if not s:
473 return None
474 return json.loads(s)
475
476
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200477def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100478 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000479 """
480 Queries a gerrit-on-borg server for changes matching query terms.
481
482 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200483 params: A list of key:value pairs for search parameters, as documented
484 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
485 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000486 first_param: A change identifier
487 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100488 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000489 o_params: A list of additional output specifiers, as documented here:
490 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
491 Returns:
492 A list of json-decoded query results.
493 """
494 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200495 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000496 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200497 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100498 if start:
499 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000500 if limit:
501 path = '%s&n=%d' % (path, limit)
502 if o_params:
503 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700504 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000505
506
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200507def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100508 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000509 """
510 Queries a gerrit-on-borg server for all the changes matching the query terms.
511
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100512 WARNING: this is unreliable if a change matching the query is modified while
513 this function is being called.
514
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000515 A single query to gerrit-on-borg is limited on the number of results by the
516 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100517 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000518
519 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200520 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000521 limit: Maximum number of requested changes per query.
522 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100523 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000524
525 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100526 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000527 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100528 already_returned = set()
529 def at_most_once(cls):
530 for cl in cls:
531 if cl['_number'] not in already_returned:
532 already_returned.add(cl['_number'])
533 yield cl
534
535 start = start or 0
536 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000537 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100538
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000539 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100540 # This will fetch changes[start..start+limit] sorted by most recently
541 # updated. Since the rank of any change in this list can be changed any time
542 # (say user posting comment), subsequent calls may overalp like this:
543 # > initial order ABCDEFGH
544 # query[0..3] => ABC
545 # > E get's updated. New order: EABCDFGH
546 # query[3..6] => CDF # C is a dup
547 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200548 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100549 cur_start)
550 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000551 yield cl
552
553 more_changes = [cl for cl in page if '_more_changes' in cl]
554 if len(more_changes) > 1:
555 raise GerritError(
556 200,
557 'Received %d changes with a _more_changes attribute set but should '
558 'receive at most one.' % len(more_changes))
559 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100560 cur_start += len(page)
561
562 # If we paged through, query again the first page which in most circumstances
563 # will fetch all changes that were modified while this function was run.
564 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200565 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100566 for cl in at_most_once(page):
567 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000568
569
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200570def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100571 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000572 """Initiate a query composed of multiple sets of query parameters."""
573 if not change_list:
574 raise RuntimeError(
575 "MultiQueryChanges requires a list of change numbers/id's")
576 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200577 if params:
578 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000579 if limit:
580 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100581 if start:
582 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000583 if o_params:
584 q.extend(['o=%s' % p for p in o_params])
585 path = 'changes/?%s' % '&'.join(q)
586 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700587 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000588 except GerritError as e:
589 msg = '%s:\n%s' % (e.message, path)
590 raise GerritError(e.http_status, msg)
591 return result
592
593
594def GetGerritFetchUrl(host):
595 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
596 return '%s://%s/' % (GERRIT_PROTOCOL, host)
597
598
599def GetChangePageUrl(host, change_number):
600 """Given a gerrit host name and change number, return change page url."""
601 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
602
603
604def GetChangeUrl(host, change):
605 """Given a gerrit host name and change id, return an url for the change."""
606 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
607
608
609def GetChange(host, change):
610 """Query a gerrit server for information about a single change."""
611 path = 'changes/%s' % change
612 return ReadHttpJsonResponse(CreateHttpConn(host, path))
613
614
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700615def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000616 """Query a gerrit server for extended information about a single change."""
617 path = 'changes/%s/detail' % change
618 if o_params:
619 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700620 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000621
622
agable32978d92016-11-01 12:55:02 -0700623def GetChangeCommit(host, change, revision='current'):
624 """Query a gerrit server for a revision associated with a change."""
625 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
626 return ReadHttpJsonResponse(CreateHttpConn(host, path))
627
628
szager@chromium.orgb4696232013-10-16 19:45:35 +0000629def GetChangeCurrentRevision(host, change):
630 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200631 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000632
633
634def GetChangeRevisions(host, change):
635 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200636 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000637
638
639def GetChangeReview(host, change, revision=None):
640 """Get the current review information for a change."""
641 if not revision:
642 jmsg = GetChangeRevisions(host, change)
643 if not jmsg:
644 return None
645 elif len(jmsg) > 1:
646 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
647 revision = jmsg[0]['current_revision']
648 path = 'changes/%s/revisions/%s/review'
649 return ReadHttpJsonResponse(CreateHttpConn(host, path))
650
651
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700652def GetChangeComments(host, change):
653 """Get the line- and file-level comments on a change."""
654 path = 'changes/%s/comments' % change
655 return ReadHttpJsonResponse(CreateHttpConn(host, path))
656
657
szager@chromium.orgb4696232013-10-16 19:45:35 +0000658def AbandonChange(host, change, msg=''):
659 """Abandon a gerrit change."""
660 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000661 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000662 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700663 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664
665
666def RestoreChange(host, change, msg=''):
667 """Restore a previously abandoned change."""
668 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000669 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000670 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700671 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000672
673
674def SubmitChange(host, change, wait_for_merge=True):
675 """Submits a gerrit change via Gerrit."""
676 path = 'changes/%s/submit' % change
677 body = {'wait_for_merge': wait_for_merge}
678 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700679 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000680
681
dsansomee2d6fd92016-09-08 00:10:47 -0700682def HasPendingChangeEdit(host, change):
683 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
684 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700685 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700686 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700687 # 204 No Content means no pending change.
688 if e.http_status == 204:
689 return False
690 raise
691 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700692
693
694def DeletePendingChangeEdit(host, change):
695 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700696 # On success, gerrit returns status 204; if the edit was already deleted it
697 # returns 404. Anything else is an error.
698 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700699
700
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100701def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000702 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700703 assert notify in ('ALL', 'NONE')
704 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700705 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700706 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000707 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700708 ReadHttpResponse(conn, accept_statuses=[200, 204])
709 except GerritError as e:
710 raise GerritError(
711 e.http_status,
712 'Received unexpected http status while editing message '
713 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000714
715
szager@chromium.orgb4696232013-10-16 19:45:35 +0000716def GetReviewers(host, change):
717 """Get information about all reviewers attached to a change."""
718 path = 'changes/%s/reviewers' % change
719 return ReadHttpJsonResponse(CreateHttpConn(host, path))
720
721
722def GetReview(host, change, revision):
723 """Get review information about a specific revision of a change."""
724 path = 'changes/%s/revisions/%s/review' % (change, revision)
725 return ReadHttpJsonResponse(CreateHttpConn(host, path))
726
727
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700728def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
729 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000730 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700731 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800732 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200733 if not change:
734 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700735 reviewers = frozenset(reviewers or [])
736 ccs = frozenset(ccs or [])
737 path = 'changes/%s/revisions/current/review' % change
738
739 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800740 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700741 'reviewers': [],
742 'notify': 'ALL' if notify else 'NONE',
743 }
744 for r in sorted(reviewers | ccs):
745 state = 'REVIEWER' if r in reviewers else 'CC'
746 body['reviewers'].append({
747 'reviewer': r,
748 'state': state,
749 'notify': 'NONE', # We handled `notify` argument above.
750 })
751
752 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
753 # Gerrit will return 400 if one or more of the requested reviewers are
754 # unprocessable. We read the response object to see which were rejected,
755 # warn about them, and retry with the remainder.
756 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
757
758 errored = set()
759 for result in resp.get('reviewers', {}).itervalues():
760 r = result.get('input')
761 state = 'REVIEWER' if r in reviewers else 'CC'
762 if result.get('error'):
763 errored.add(r)
764 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
765 if errored:
766 # Try again, adding only those that didn't fail, and only accepting 200.
767 AddReviewers(host, change, reviewers=(reviewers-errored),
768 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000769
770
771def RemoveReviewers(host, change, remove=None):
772 """Remove reveiewers from a change."""
773 if not remove:
774 return
775 if isinstance(remove, basestring):
776 remove = (remove,)
777 for r in remove:
778 path = 'changes/%s/reviewers/%s' % (change, r)
779 conn = CreateHttpConn(host, path, reqtype='DELETE')
780 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700781 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000782 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000783 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700784 e.http_status,
785 'Received unexpected http status while deleting reviewer "%s" '
786 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000787
788
Aaron Gable636b13f2017-07-14 10:42:48 -0700789def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000790 """Set labels and/or add a message to a code review."""
791 if not msg and not labels:
792 return
793 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800794 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000795 if msg:
796 body['message'] = msg
797 if labels:
798 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700799 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700800 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700801 if ready:
802 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000803 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
804 response = ReadHttpJsonResponse(conn)
805 if labels:
806 for key, val in labels.iteritems():
807 if ('labels' not in response or key not in response['labels'] or
808 int(response['labels'][key] != int(val))):
809 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
810 key, change))
811
812
813def ResetReviewLabels(host, change, label, value='0', message=None,
814 notify=None):
815 """Reset the value of a given label for all reviewers on a change."""
816 # This is tricky, because we want to work on the "current revision", but
817 # there's always the risk that "current revision" will change in between
818 # API calls. So, we check "current revision" at the beginning and end; if
819 # it has changed, raise an exception.
820 jmsg = GetChangeCurrentRevision(host, change)
821 if not jmsg:
822 raise GerritError(
823 200, 'Could not get review information for change "%s"' % change)
824 value = str(value)
825 revision = jmsg[0]['current_revision']
826 path = 'changes/%s/revisions/%s/review' % (change, revision)
827 message = message or (
828 '%s label set to %s programmatically.' % (label, value))
829 jmsg = GetReview(host, change, revision)
830 if not jmsg:
831 raise GerritError(200, 'Could not get review information for revison %s '
832 'of change %s' % (revision, change))
833 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
834 if str(review.get('value', value)) != value:
835 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800836 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000837 'message': message,
838 'labels': {label: value},
839 'on_behalf_of': review['_account_id'],
840 }
841 if notify:
842 body['notify'] = notify
843 conn = CreateHttpConn(
844 host, path, reqtype='POST', body=body)
845 response = ReadHttpJsonResponse(conn)
846 if str(response['labels'][label]) != value:
847 username = review.get('email', jmsg.get('name', ''))
848 raise GerritError(200, 'Unable to set %s label for user "%s"'
849 ' on change %s.' % (label, username, change))
850 jmsg = GetChangeCurrentRevision(host, change)
851 if not jmsg:
852 raise GerritError(
853 200, 'Could not get review information for change "%s"' % change)
854 elif jmsg[0]['current_revision'] != revision:
855 raise GerritError(200, 'While resetting labels on change "%s", '
856 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800857
858
dimu833c94c2017-01-18 17:36:15 -0800859def CreateGerritBranch(host, project, branch, commit):
860 """
861 Create a new branch from given project and commit
862 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
863
864 Returns:
865 A JSON with 'ref' key
866 """
867 path = 'projects/%s/branches/%s' % (project, branch)
868 body = {'revision': commit}
869 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700870 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800871 if response:
872 return response
873 raise GerritError(200, 'Unable to create gerrit branch')
874
875
876def GetGerritBranch(host, project, branch):
877 """
878 Get a branch from given project and commit
879 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
880
881 Returns:
882 A JSON object with 'revision' key
883 """
884 path = 'projects/%s/branches/%s' % (project, branch)
885 conn = CreateHttpConn(host, path, reqtype='GET')
886 response = ReadHttpJsonResponse(conn)
887 if response:
888 return response
889 raise GerritError(200, 'Unable to get gerrit branch')
890
891
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100892def GetAccountDetails(host, account_id='self'):
893 """Returns details of the account.
894
895 If account_id is not given, uses magic value 'self' which corresponds to
896 whichever account user is authenticating as.
897
898 Documentation:
899 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
900 """
901 if account_id != 'self':
902 account_id = int(account_id)
903 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
904 return ReadHttpJsonResponse(conn)
905
906
Nick Carter8692b182017-11-06 16:30:38 -0800907def PercentEncodeForGitRef(original):
908 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
909
910 The encoding used is based on but stricter than URL encoding (Section 2.1
911 of RFC 3986). The only non-escaped characters are alphanumerics, and
912 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
913 'PLUS SIGN' (U+002B).
914
915 For more information, see the Gerrit docs here:
916
917 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
918 """
919 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
920 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
921
922 # spaces are not allowed in git refs; gerrit will interpret either '_' or
923 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
924 return encoded.replace(' ', '_')
925
926
Dan Jacques8d11e482016-11-15 14:25:56 -0800927@contextlib.contextmanager
928def tempdir():
929 tdir = None
930 try:
931 tdir = tempfile.mkdtemp(suffix='gerrit_util')
932 yield tdir
933 finally:
934 if tdir:
935 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000936
937
938def ChangeIdentifier(project, change_number):
939 """Returns change identifier "project~number" suitable for |chagne| arg of
940 this module API.
941
942 Such format is allows for more efficient Gerrit routing of HTTP requests,
943 comparing to specifying just change_number.
944 """
945 assert int(change_number)
946 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000947
948
949# TODO(crbug/881860): remove this hack.
950_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3']
951assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
952
953
954def _UseGerritMirror(url, host):
955 """Returns new url which uses randomly selected mirror for a gerrit host.
956
957 url's host should be for a given host or a result of prior call to this
958 function.
959
960 Assumes url has a single occurence of the host substring.
961 """
962 assert host in url
963 suffix = '-mirror-' + host
964 prefixes = set(_GERRIT_MIRROR_PREFIXES)
965 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
966 st = url.find(suffix)
967 if st == -1:
968 actual_host = host
969 else:
970 # Already uses some mirror.
971 assert st >= prefix_len, (uri, host, st, prefix_len)
972 prefixes.remove(url[st-prefix_len:st])
973 actual_host = url[st-prefix_len:st+len(suffix)]
974 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)