blob: c837145299ba6b3c22ee3100adb9206f285e8ac8 [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 Boren2fb63102018-10-05 13:05:03 +0000222 if value.startswith('git-'):
223 login, secret_token = value.split('=', 1)
224 gitcookies[domain] = (login, secret_token)
225 else:
226 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000227 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100228 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000229 return gitcookies
230
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100231 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000232 for domain, creds in self.gitcookies.iteritems():
233 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100234 return (creds[0], None, creds[1])
235 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000236
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100237 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700238 a = self._get_auth_for_host(host)
239 if a:
Eric Boren2fb63102018-10-05 13:05:03 +0000240 if a[0]:
241 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
242 else:
243 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000244 return None
245
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100246 def get_auth_email(self, host):
247 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700248 a = self._get_auth_for_host(host)
249 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100250 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700251 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100252 # login typically looks like 'git-xxx.example.com'
253 if not login.startswith('git-') or '.' not in login:
254 return None
255 username, domain = login[len('git-'):].split('.', 1)
256 return '%s@%s' % (username, domain)
257
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100258
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000259# Backwards compatibility just in case somebody imports this outside of
260# depot_tools.
261NetrcAuthenticator = CookiesAuthenticator
262
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000263
264class GceAuthenticator(Authenticator):
265 """Authenticator implementation that uses GCE metadata service for token.
266 """
267
268 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700269 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
270 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000271 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
272
273 _cache_is_gce = None
274 _token_cache = None
275 _token_expiration = None
276
277 @classmethod
278 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500279 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
280 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000281 if cls._cache_is_gce is None:
282 cls._cache_is_gce = cls._test_is_gce()
283 return cls._cache_is_gce
284
285 @classmethod
286 def _test_is_gce(cls):
287 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
288 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100289 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100290 except (socket.error, httplib2.ServerNotFoundError,
291 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000292 # Could not resolve URL.
293 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100294 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000295
296 @staticmethod
297 def _get(url, **kwargs):
298 next_delay_sec = 1
299 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000300 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700301 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100302 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
304 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100305 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000306
Aaron Gable92e9f382017-12-07 11:47:41 -0800307 # Retry server error status codes.
308 LOGGER.warn('Encountered server error')
309 if TRY_LIMIT - i > 1:
310 LOGGER.info('Will retry in %d seconds (%d more times)...',
311 next_delay_sec, TRY_LIMIT - i - 1)
312 time.sleep(next_delay_sec)
313 next_delay_sec *= 2
314
315
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000316 @classmethod
317 def _get_token_dict(cls):
318 if cls._token_cache:
319 # If it expires within 25 seconds, refresh.
320 if cls._token_expiration < time.time() - 25:
321 return cls._token_cache
322
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100323 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000324 if resp.status != httplib.OK:
325 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100326 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000327 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
328 return cls._token_cache
329
330 def get_auth_header(self, _host):
331 token_dict = self._get_token_dict()
332 if not token_dict:
333 return None
334 return '%(token_type)s %(access_token)s' % token_dict
335
336
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700337class LuciContextAuthenticator(Authenticator):
338 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
339 """
340
341 @staticmethod
342 def is_luci():
343 return auth.has_luci_context_local_auth()
344
345 def __init__(self):
346 self._access_token = None
347 self._ensure_fresh()
348
349 def _ensure_fresh(self):
350 if not self._access_token or self._access_token.needs_refresh():
351 self._access_token = auth.get_luci_context_access_token(
352 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
353
354 def get_auth_header(self, _host):
355 self._ensure_fresh()
356 return 'Bearer %s' % self._access_token.token
357
358
szager@chromium.orgb4696232013-10-16 19:45:35 +0000359def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
360 """Opens an https connection to a gerrit service, and sends a request."""
361 headers = headers or {}
362 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000363
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700364 a = Authenticator.get().get_auth_header(bare_host)
365 if a:
366 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000367 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000368 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000369
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800370 url = path
371 if not url.startswith('/'):
372 url = '/' + url
373 if 'Authorization' in headers and not url.startswith('/a/'):
374 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000375
szager@chromium.orgb4696232013-10-16 19:45:35 +0000376 if body:
377 body = json.JSONEncoder().encode(body)
378 headers.setdefault('Content-Type', 'application/json')
379 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000380 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000381 for key, val in headers.iteritems():
382 if key == 'Authorization':
383 val = 'HIDDEN'
384 LOGGER.debug('%s: %s' % (key, val))
385 if body:
386 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700387 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000388 # HACK: httplib.Http has no such attribute; we store req_host here for later
389 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000390 conn.req_host = host
391 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100392 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000393 'method': reqtype,
394 'headers': headers,
395 'body': body,
396 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000397 return conn
398
399
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700400def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000401 """Reads an http response from a connection into a string buffer.
402
403 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100404 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700405 accept_statuses: Treat any of these statuses as success. Default: [200]
406 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000407 Returns: A string buffer containing the connection's reply.
408 """
Steve Kobes56117722018-09-13 18:18:35 +0000409 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000410 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100411 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000412
413 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100414 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000415 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
416 www_authenticate):
417 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
418 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700419 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000420 'has credentials for %s' % host)
421 raise GerritAuthenticationError(response.status, reason)
422
szager@chromium.orgb4696232013-10-16 19:45:35 +0000423 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000424 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700425 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000426 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700427 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100428 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100429 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700430 # If 404 was in accept_statuses, then it's expected that the file might
431 # not exist, so don't return the gitiles error page because that's not the
432 # "content" that was actually requested.
433 if response.status == 404:
434 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000435 break
436 # A status >=500 is assumed to be a possible transient error; retry.
437 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andy Perelsona06cd092018-09-24 21:29:57 +0000438 LOGGER.warn('A transient error occurred while querying %s:\n'
439 '%s %s %s\n'
440 '%s %d %s',
441 conn.req_host, conn.req_params['method'],
442 conn.req_params['uri'],
443 http_version, http_version, response.status, response.reason)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000444 if response.status == 404:
445 # TODO(crbug/881860): remove this hack.
446 # HACK: try different Gerrit mirror as a workaround for potentially
447 # out-of-date mirror hit through default routing.
448 if conn.req_host == 'chromium-review.googlesource.com':
449 conn.req_params['uri'] = _UseGerritMirror(
450 conn.req_params['uri'], 'chromium-review.googlesource.com')
451 # And don't increase sleep_time in this case, since we suspect we've
452 # just asked wrong git mirror before.
453 sleep_time /= 2.0
454
szager@chromium.orgb4696232013-10-16 19:45:35 +0000455 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800456 LOGGER.info('Will retry in %d seconds (%d more times)...',
457 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000458 time.sleep(sleep_time)
459 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700460 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200461 if response.status in (401, 403):
462 print('Your Gerrit credentials might be misconfigured. Try: \n'
463 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100464 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000465 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100466 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000467
468
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700469def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000470 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700471 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000472 # The first line of the response should always be: )]}'
473 s = fh.readline()
474 if s and s.rstrip() != ")]}'":
475 raise GerritError(200, 'Unexpected json output: %s' % s)
476 s = fh.read()
477 if not s:
478 return None
479 return json.loads(s)
480
481
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200482def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100483 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000484 """
485 Queries a gerrit-on-borg server for changes matching query terms.
486
487 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200488 params: A list of key:value pairs for search parameters, as documented
489 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
490 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000491 first_param: A change identifier
492 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100493 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000494 o_params: A list of additional output specifiers, as documented here:
495 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
496 Returns:
497 A list of json-decoded query results.
498 """
499 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200500 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000501 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200502 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100503 if start:
504 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000505 if limit:
506 path = '%s&n=%d' % (path, limit)
507 if o_params:
508 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700509 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000510
511
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200512def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100513 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000514 """
515 Queries a gerrit-on-borg server for all the changes matching the query terms.
516
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100517 WARNING: this is unreliable if a change matching the query is modified while
518 this function is being called.
519
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000520 A single query to gerrit-on-borg is limited on the number of results by the
521 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100522 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000523
524 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200525 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000526 limit: Maximum number of requested changes per query.
527 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100528 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000529
530 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000532 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100533 already_returned = set()
534 def at_most_once(cls):
535 for cl in cls:
536 if cl['_number'] not in already_returned:
537 already_returned.add(cl['_number'])
538 yield cl
539
540 start = start or 0
541 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000542 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100543
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000544 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100545 # This will fetch changes[start..start+limit] sorted by most recently
546 # updated. Since the rank of any change in this list can be changed any time
547 # (say user posting comment), subsequent calls may overalp like this:
548 # > initial order ABCDEFGH
549 # query[0..3] => ABC
550 # > E get's updated. New order: EABCDFGH
551 # query[3..6] => CDF # C is a dup
552 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200553 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100554 cur_start)
555 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000556 yield cl
557
558 more_changes = [cl for cl in page if '_more_changes' in cl]
559 if len(more_changes) > 1:
560 raise GerritError(
561 200,
562 'Received %d changes with a _more_changes attribute set but should '
563 'receive at most one.' % len(more_changes))
564 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100565 cur_start += len(page)
566
567 # If we paged through, query again the first page which in most circumstances
568 # will fetch all changes that were modified while this function was run.
569 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200570 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100571 for cl in at_most_once(page):
572 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000573
574
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200575def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100576 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000577 """Initiate a query composed of multiple sets of query parameters."""
578 if not change_list:
579 raise RuntimeError(
580 "MultiQueryChanges requires a list of change numbers/id's")
581 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200582 if params:
583 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000584 if limit:
585 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100586 if start:
587 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000588 if o_params:
589 q.extend(['o=%s' % p for p in o_params])
590 path = 'changes/?%s' % '&'.join(q)
591 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700592 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000593 except GerritError as e:
594 msg = '%s:\n%s' % (e.message, path)
595 raise GerritError(e.http_status, msg)
596 return result
597
598
599def GetGerritFetchUrl(host):
600 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
601 return '%s://%s/' % (GERRIT_PROTOCOL, host)
602
603
604def GetChangePageUrl(host, change_number):
605 """Given a gerrit host name and change number, return change page url."""
606 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
607
608
609def GetChangeUrl(host, change):
610 """Given a gerrit host name and change id, return an url for the change."""
611 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
612
613
614def GetChange(host, change):
615 """Query a gerrit server for information about a single change."""
616 path = 'changes/%s' % change
617 return ReadHttpJsonResponse(CreateHttpConn(host, path))
618
619
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700620def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000621 """Query a gerrit server for extended information about a single change."""
622 path = 'changes/%s/detail' % change
623 if o_params:
624 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700625 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000626
627
agable32978d92016-11-01 12:55:02 -0700628def GetChangeCommit(host, change, revision='current'):
629 """Query a gerrit server for a revision associated with a change."""
630 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
631 return ReadHttpJsonResponse(CreateHttpConn(host, path))
632
633
szager@chromium.orgb4696232013-10-16 19:45:35 +0000634def GetChangeCurrentRevision(host, change):
635 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200636 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000637
638
639def GetChangeRevisions(host, change):
640 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200641 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000642
643
644def GetChangeReview(host, change, revision=None):
645 """Get the current review information for a change."""
646 if not revision:
647 jmsg = GetChangeRevisions(host, change)
648 if not jmsg:
649 return None
650 elif len(jmsg) > 1:
651 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
652 revision = jmsg[0]['current_revision']
653 path = 'changes/%s/revisions/%s/review'
654 return ReadHttpJsonResponse(CreateHttpConn(host, path))
655
656
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700657def GetChangeComments(host, change):
658 """Get the line- and file-level comments on a change."""
659 path = 'changes/%s/comments' % change
660 return ReadHttpJsonResponse(CreateHttpConn(host, path))
661
662
szager@chromium.orgb4696232013-10-16 19:45:35 +0000663def AbandonChange(host, change, msg=''):
664 """Abandon a gerrit change."""
665 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000666 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000667 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700668 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000669
670
671def RestoreChange(host, change, msg=''):
672 """Restore a previously abandoned change."""
673 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000674 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000675 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700676 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000677
678
679def SubmitChange(host, change, wait_for_merge=True):
680 """Submits a gerrit change via Gerrit."""
681 path = 'changes/%s/submit' % change
682 body = {'wait_for_merge': wait_for_merge}
683 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700684 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000685
686
dsansomee2d6fd92016-09-08 00:10:47 -0700687def HasPendingChangeEdit(host, change):
688 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
689 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700690 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700691 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700692 # 204 No Content means no pending change.
693 if e.http_status == 204:
694 return False
695 raise
696 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700697
698
699def DeletePendingChangeEdit(host, change):
700 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700701 # On success, gerrit returns status 204; if the edit was already deleted it
702 # returns 404. Anything else is an error.
703 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700704
705
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100706def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000707 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700708 assert notify in ('ALL', 'NONE')
709 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700710 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700711 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000712 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700713 ReadHttpResponse(conn, accept_statuses=[200, 204])
714 except GerritError as e:
715 raise GerritError(
716 e.http_status,
717 'Received unexpected http status while editing message '
718 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000719
720
szager@chromium.orgb4696232013-10-16 19:45:35 +0000721def GetReviewers(host, change):
722 """Get information about all reviewers attached to a change."""
723 path = 'changes/%s/reviewers' % change
724 return ReadHttpJsonResponse(CreateHttpConn(host, path))
725
726
727def GetReview(host, change, revision):
728 """Get review information about a specific revision of a change."""
729 path = 'changes/%s/revisions/%s/review' % (change, revision)
730 return ReadHttpJsonResponse(CreateHttpConn(host, path))
731
732
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700733def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
734 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000735 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700736 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800737 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200738 if not change:
739 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700740 reviewers = frozenset(reviewers or [])
741 ccs = frozenset(ccs or [])
742 path = 'changes/%s/revisions/current/review' % change
743
744 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800745 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700746 'reviewers': [],
747 'notify': 'ALL' if notify else 'NONE',
748 }
749 for r in sorted(reviewers | ccs):
750 state = 'REVIEWER' if r in reviewers else 'CC'
751 body['reviewers'].append({
752 'reviewer': r,
753 'state': state,
754 'notify': 'NONE', # We handled `notify` argument above.
755 })
756
757 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
758 # Gerrit will return 400 if one or more of the requested reviewers are
759 # unprocessable. We read the response object to see which were rejected,
760 # warn about them, and retry with the remainder.
761 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
762
763 errored = set()
764 for result in resp.get('reviewers', {}).itervalues():
765 r = result.get('input')
766 state = 'REVIEWER' if r in reviewers else 'CC'
767 if result.get('error'):
768 errored.add(r)
769 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
770 if errored:
771 # Try again, adding only those that didn't fail, and only accepting 200.
772 AddReviewers(host, change, reviewers=(reviewers-errored),
773 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000774
775
776def RemoveReviewers(host, change, remove=None):
777 """Remove reveiewers from a change."""
778 if not remove:
779 return
780 if isinstance(remove, basestring):
781 remove = (remove,)
782 for r in remove:
783 path = 'changes/%s/reviewers/%s' % (change, r)
784 conn = CreateHttpConn(host, path, reqtype='DELETE')
785 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700786 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000787 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000788 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700789 e.http_status,
790 'Received unexpected http status while deleting reviewer "%s" '
791 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000792
793
Aaron Gable636b13f2017-07-14 10:42:48 -0700794def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000795 """Set labels and/or add a message to a code review."""
796 if not msg and not labels:
797 return
798 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800799 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000800 if msg:
801 body['message'] = msg
802 if labels:
803 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700804 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700805 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700806 if ready:
807 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000808 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
809 response = ReadHttpJsonResponse(conn)
810 if labels:
811 for key, val in labels.iteritems():
812 if ('labels' not in response or key not in response['labels'] or
813 int(response['labels'][key] != int(val))):
814 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
815 key, change))
816
817
818def ResetReviewLabels(host, change, label, value='0', message=None,
819 notify=None):
820 """Reset the value of a given label for all reviewers on a change."""
821 # This is tricky, because we want to work on the "current revision", but
822 # there's always the risk that "current revision" will change in between
823 # API calls. So, we check "current revision" at the beginning and end; if
824 # it has changed, raise an exception.
825 jmsg = GetChangeCurrentRevision(host, change)
826 if not jmsg:
827 raise GerritError(
828 200, 'Could not get review information for change "%s"' % change)
829 value = str(value)
830 revision = jmsg[0]['current_revision']
831 path = 'changes/%s/revisions/%s/review' % (change, revision)
832 message = message or (
833 '%s label set to %s programmatically.' % (label, value))
834 jmsg = GetReview(host, change, revision)
835 if not jmsg:
836 raise GerritError(200, 'Could not get review information for revison %s '
837 'of change %s' % (revision, change))
838 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
839 if str(review.get('value', value)) != value:
840 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800841 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000842 'message': message,
843 'labels': {label: value},
844 'on_behalf_of': review['_account_id'],
845 }
846 if notify:
847 body['notify'] = notify
848 conn = CreateHttpConn(
849 host, path, reqtype='POST', body=body)
850 response = ReadHttpJsonResponse(conn)
851 if str(response['labels'][label]) != value:
852 username = review.get('email', jmsg.get('name', ''))
853 raise GerritError(200, 'Unable to set %s label for user "%s"'
854 ' on change %s.' % (label, username, change))
855 jmsg = GetChangeCurrentRevision(host, change)
856 if not jmsg:
857 raise GerritError(
858 200, 'Could not get review information for change "%s"' % change)
859 elif jmsg[0]['current_revision'] != revision:
860 raise GerritError(200, 'While resetting labels on change "%s", '
861 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800862
863
dimu833c94c2017-01-18 17:36:15 -0800864def CreateGerritBranch(host, project, branch, commit):
865 """
866 Create a new branch from given project and commit
867 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
868
869 Returns:
870 A JSON with 'ref' key
871 """
872 path = 'projects/%s/branches/%s' % (project, branch)
873 body = {'revision': commit}
874 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700875 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800876 if response:
877 return response
878 raise GerritError(200, 'Unable to create gerrit branch')
879
880
881def GetGerritBranch(host, project, branch):
882 """
883 Get a branch from given project and commit
884 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
885
886 Returns:
887 A JSON object with 'revision' key
888 """
889 path = 'projects/%s/branches/%s' % (project, branch)
890 conn = CreateHttpConn(host, path, reqtype='GET')
891 response = ReadHttpJsonResponse(conn)
892 if response:
893 return response
894 raise GerritError(200, 'Unable to get gerrit branch')
895
896
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100897def GetAccountDetails(host, account_id='self'):
898 """Returns details of the account.
899
900 If account_id is not given, uses magic value 'self' which corresponds to
901 whichever account user is authenticating as.
902
903 Documentation:
904 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
905 """
906 if account_id != 'self':
907 account_id = int(account_id)
908 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
909 return ReadHttpJsonResponse(conn)
910
911
Nick Carter8692b182017-11-06 16:30:38 -0800912def PercentEncodeForGitRef(original):
913 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
914
915 The encoding used is based on but stricter than URL encoding (Section 2.1
916 of RFC 3986). The only non-escaped characters are alphanumerics, and
917 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
918 'PLUS SIGN' (U+002B).
919
920 For more information, see the Gerrit docs here:
921
922 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
923 """
924 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
925 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
926
927 # spaces are not allowed in git refs; gerrit will interpret either '_' or
928 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
929 return encoded.replace(' ', '_')
930
931
Dan Jacques8d11e482016-11-15 14:25:56 -0800932@contextlib.contextmanager
933def tempdir():
934 tdir = None
935 try:
936 tdir = tempfile.mkdtemp(suffix='gerrit_util')
937 yield tdir
938 finally:
939 if tdir:
940 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000941
942
943def ChangeIdentifier(project, change_number):
944 """Returns change identifier "project~number" suitable for |chagne| arg of
945 this module API.
946
947 Such format is allows for more efficient Gerrit routing of HTTP requests,
948 comparing to specifying just change_number.
949 """
950 assert int(change_number)
951 return '%s~%s' % (urllib.quote(project, safe=''), change_number)
Andrii Shyshkalovd4c86732018-09-25 04:29:31 +0000952
953
954# TODO(crbug/881860): remove this hack.
955_GERRIT_MIRROR_PREFIXES = ['us1', 'us2', 'us3']
956assert all(3 == len(p) for p in _GERRIT_MIRROR_PREFIXES)
957
958
959def _UseGerritMirror(url, host):
960 """Returns new url which uses randomly selected mirror for a gerrit host.
961
962 url's host should be for a given host or a result of prior call to this
963 function.
964
965 Assumes url has a single occurence of the host substring.
966 """
967 assert host in url
968 suffix = '-mirror-' + host
969 prefixes = set(_GERRIT_MIRROR_PREFIXES)
970 prefix_len = len(_GERRIT_MIRROR_PREFIXES[0])
971 st = url.find(suffix)
972 if st == -1:
973 actual_host = host
974 else:
975 # Already uses some mirror.
976 assert st >= prefix_len, (uri, host, st, prefix_len)
977 prefixes.remove(url[st-prefix_len:st])
978 actual_host = url[st-prefix_len:st+len(suffix)]
979 return url.replace(actual_host, random.choice(list(prefixes)) + suffix)