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