blob: 9b5214660d87a063c78e2ddae8d4f48e2116c152 [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
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000019import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000020import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000021import stat
22import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080023import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000024import time
25import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000026import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000027from cStringIO import StringIO
28
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070029import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080030import gclient_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080031import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010032from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000033
szager@chromium.orgb4696232013-10-16 19:45:35 +000034LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000035# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
36# total tries, the sleep time between the first and last tries will be 94.5 sec.
37# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
38TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000039
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000040
szager@chromium.orgb4696232013-10-16 19:45:35 +000041# Controls the transport protocol used to communicate with gerrit.
42# This is parameterized primarily to enable GerritTestCase.
43GERRIT_PROTOCOL = 'https'
44
45
46class GerritError(Exception):
47 """Exception class for errors commuicating with the gerrit-on-borg service."""
48 def __init__(self, http_status, *args, **kwargs):
49 super(GerritError, self).__init__(*args, **kwargs)
50 self.http_status = http_status
51 self.message = '(%d) %s' % (self.http_status, self.message)
52
53
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000054class GerritAuthenticationError(GerritError):
55 """Exception class for authentication errors during Gerrit communication."""
56
57
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020058def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000059 """Encodes query parameters in the key:val[+key:val...] format specified here:
60
61 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
62 """
63 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020064 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000065 return '+'.join(q)
66
67
Aaron Gabled2db5a22017-03-24 14:14:15 -070068def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000069 if protocol is None:
70 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010071 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070072 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000073 else:
74 raise RuntimeError(
75 "Don't know how to work with protocol '%s'" % protocol)
76
77
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000078class Authenticator(object):
79 """Base authenticator class for authenticator implementations to subclass."""
80
81 def get_auth_header(self, host):
82 raise NotImplementedError()
83
84 @staticmethod
85 def get():
86 """Returns: (Authenticator) The identified Authenticator to use.
87
88 Probes the local system and its environment and identifies the
89 Authenticator instance to use.
90 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070091 # LUCI Context takes priority since it's normally present only on bots,
92 # which then must use it.
93 if LuciContextAuthenticator.is_luci():
94 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000095 if GceAuthenticator.is_gce():
96 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000097 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000098
99
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000100class CookiesAuthenticator(Authenticator):
101 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
102
103 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000104 """
105
106 def __init__(self):
107 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000108 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000109
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000110 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200111 def get_new_password_url(cls, host):
112 assert not host.startswith('http')
113 # Assume *.googlesource.com pattern.
114 parts = host.split('.')
115 if not parts[0].endswith('-review'):
116 parts[0] += '-review'
117 return 'https://%s/new-password' % ('.'.join(parts))
118
119 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000120 def get_new_password_message(cls, host):
121 assert not host.startswith('http')
122 # Assume *.googlesource.com pattern.
123 parts = host.split('.')
124 if not parts[0].endswith('-review'):
125 parts[0] += '-review'
126 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100127 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000128
129 @classmethod
130 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000131 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000132 return os.path.expanduser(os.path.join('~', path))
133
134 @classmethod
135 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800136 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000137 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000138 if not os.path.exists(path):
139 return netrc.netrc(os.devnull)
140
141 st = os.stat(path)
142 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
143 print >> sys.stderr, (
144 'WARNING: netrc file %s cannot be used because its file '
145 'permissions are insecure. netrc file permissions should be '
146 '600.' % path)
147 with open(path) as fd:
148 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800149
150 # Load the '.netrc' file. We strip comments from it because processing them
151 # can trigger a bug in Windows. See crbug.com/664664.
152 content = '\n'.join(l for l in content.splitlines()
153 if l.strip() and not l.strip().startswith('#'))
154 with tempdir() as tdir:
155 netrc_path = os.path.join(tdir, 'netrc')
156 with open(netrc_path, 'w') as fd:
157 fd.write(content)
158 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
159 return cls._get_netrc_from_path(netrc_path)
160
161 @classmethod
162 def _get_netrc_from_path(cls, path):
163 try:
164 return netrc.netrc(path)
165 except IOError:
166 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
167 return netrc.netrc(os.devnull)
168 except netrc.NetrcParseError as e:
169 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
170 'parsing error: %s' % (path, e))
171 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000172
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000173 @classmethod
174 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500175 if os.getenv('GIT_COOKIES_PATH'):
176 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800177 try:
178 return subprocess2.check_output(
179 ['git', 'config', '--path', 'http.cookiefile']).strip()
180 except subprocess2.CalledProcessError:
181 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000182
183 @classmethod
184 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000185 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000186 path = cls.get_gitcookies_path()
187 if not os.path.exists(path):
188 return gitcookies
189
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000190 try:
191 f = open(path, 'rb')
192 except IOError:
193 return gitcookies
194
195 with f:
196 for line in f:
197 try:
198 fields = line.strip().split('\t')
199 if line.strip().startswith('#') or len(fields) != 7:
200 continue
201 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
202 if xpath == '/' and key == 'o':
Eric Boren18b44792018-09-18 11:32:32 +0000203 if value.startswith('git-'):
204 login, secret_token = value.split('=', 1)
205 gitcookies[domain] = (login, secret_token)
206 else:
207 gitcookies[domain] = ('', value)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000208 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100209 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000210 return gitcookies
211
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100212 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000213 for domain, creds in self.gitcookies.iteritems():
214 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100215 return (creds[0], None, creds[1])
216 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000217
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100218 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700219 a = self._get_auth_for_host(host)
220 if a:
Eric Boren18b44792018-09-18 11:32:32 +0000221 if a[0]:
222 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
223 else:
224 return 'Bearer %s' % a[2]
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000225 return None
226
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100227 def get_auth_email(self, host):
228 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700229 a = self._get_auth_for_host(host)
230 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100231 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700232 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100233 # login typically looks like 'git-xxx.example.com'
234 if not login.startswith('git-') or '.' not in login:
235 return None
236 username, domain = login[len('git-'):].split('.', 1)
237 return '%s@%s' % (username, domain)
238
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100239
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000240# Backwards compatibility just in case somebody imports this outside of
241# depot_tools.
242NetrcAuthenticator = CookiesAuthenticator
243
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000244
245class GceAuthenticator(Authenticator):
246 """Authenticator implementation that uses GCE metadata service for token.
247 """
248
249 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700250 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
251 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000252 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
253
254 _cache_is_gce = None
255 _token_cache = None
256 _token_expiration = None
257
258 @classmethod
259 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500260 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
261 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000262 if cls._cache_is_gce is None:
263 cls._cache_is_gce = cls._test_is_gce()
264 return cls._cache_is_gce
265
266 @classmethod
267 def _test_is_gce(cls):
268 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
269 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100270 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100271 except (socket.error, httplib2.ServerNotFoundError,
272 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000273 # Could not resolve URL.
274 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100275 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000276
277 @staticmethod
278 def _get(url, **kwargs):
279 next_delay_sec = 1
280 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000281 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700282 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100283 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000284 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
285 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100286 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000287
Aaron Gable92e9f382017-12-07 11:47:41 -0800288 # Retry server error status codes.
289 LOGGER.warn('Encountered server error')
290 if TRY_LIMIT - i > 1:
291 LOGGER.info('Will retry in %d seconds (%d more times)...',
292 next_delay_sec, TRY_LIMIT - i - 1)
293 time.sleep(next_delay_sec)
294 next_delay_sec *= 2
295
296
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000297 @classmethod
298 def _get_token_dict(cls):
299 if cls._token_cache:
300 # If it expires within 25 seconds, refresh.
301 if cls._token_expiration < time.time() - 25:
302 return cls._token_cache
303
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100304 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000305 if resp.status != httplib.OK:
306 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100307 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000308 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
309 return cls._token_cache
310
311 def get_auth_header(self, _host):
312 token_dict = self._get_token_dict()
313 if not token_dict:
314 return None
315 return '%(token_type)s %(access_token)s' % token_dict
316
317
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700318class LuciContextAuthenticator(Authenticator):
319 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
320 """
321
322 @staticmethod
323 def is_luci():
324 return auth.has_luci_context_local_auth()
325
326 def __init__(self):
327 self._access_token = None
328 self._ensure_fresh()
329
330 def _ensure_fresh(self):
331 if not self._access_token or self._access_token.needs_refresh():
332 self._access_token = auth.get_luci_context_access_token(
333 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
334
335 def get_auth_header(self, _host):
336 self._ensure_fresh()
337 return 'Bearer %s' % self._access_token.token
338
339
szager@chromium.orgb4696232013-10-16 19:45:35 +0000340def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
341 """Opens an https connection to a gerrit service, and sends a request."""
342 headers = headers or {}
343 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000344
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700345 a = Authenticator.get().get_auth_header(bare_host)
346 if a:
347 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000348 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000349 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000350
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800351 url = path
352 if not url.startswith('/'):
353 url = '/' + url
354 if 'Authorization' in headers and not url.startswith('/a/'):
355 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000356
szager@chromium.orgb4696232013-10-16 19:45:35 +0000357 if body:
358 body = json.JSONEncoder().encode(body)
359 headers.setdefault('Content-Type', 'application/json')
360 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000361 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000362 for key, val in headers.iteritems():
363 if key == 'Authorization':
364 val = 'HIDDEN'
365 LOGGER.debug('%s: %s' % (key, val))
366 if body:
367 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700368 conn = GetConnectionObject()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000369 conn.req_host = host
370 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100371 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000372 'method': reqtype,
373 'headers': headers,
374 'body': body,
375 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000376 return conn
377
378
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700379def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000380 """Reads an http response from a connection into a string buffer.
381
382 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100383 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700384 accept_statuses: Treat any of these statuses as success. Default: [200]
385 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000386 Returns: A string buffer containing the connection's reply.
387 """
Steve Kobes56117722018-09-13 18:18:35 +0000388 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000389 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100390 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000391
392 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100393 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000394 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
395 www_authenticate):
396 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
397 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700398 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000399 'has credentials for %s' % host)
400 raise GerritAuthenticationError(response.status, reason)
401
szager@chromium.orgb4696232013-10-16 19:45:35 +0000402 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000403 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700404 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000405 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700406 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100407 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100408 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700409 # If 404 was in accept_statuses, then it's expected that the file might
410 # not exist, so don't return the gitiles error page because that's not the
411 # "content" that was actually requested.
412 if response.status == 404:
413 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000414 break
415 # A status >=500 is assumed to be a possible transient error; retry.
416 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalovea4301e2018-09-17 18:48:23 +0000417
418 # TODO(crbug/881860): remove this special 404 handling.
419 if response.status == 404:
420 LOGGER.warn(
421 '404 NotFound error occurred while querying %s %s: %s\n'
422 'NOTE: if see this while running `git cl upload`,\n'
423 'consider reporting this to https://crbug.com/881860.\n'
424 'Please, include response headers below:\n'
425 ' %s\n',
426 conn.req_params['method'], conn.req_params['uri'], response.reason,
427 '\n '.join(
428 json.dumps(response, sort_keys=True, indent=2).splitlines()))
429 else:
430 LOGGER.warn('A transient error occurred while querying %s:\n'
431 '%s %s\n'
432 '%s %d %s\n',
433 conn.req_host,
434 conn.req_params['method'], conn.req_params['uri'],
435 http_version, response.status, response.reason)
436
szager@chromium.orgb4696232013-10-16 19:45:35 +0000437 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800438 LOGGER.info('Will retry in %d seconds (%d more times)...',
439 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000440 time.sleep(sleep_time)
441 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700442 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200443 if response.status in (401, 403):
444 print('Your Gerrit credentials might be misconfigured. Try: \n'
445 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100446 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000447 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100448 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000449
450
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700451def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000452 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700453 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000454 # The first line of the response should always be: )]}'
455 s = fh.readline()
456 if s and s.rstrip() != ")]}'":
457 raise GerritError(200, 'Unexpected json output: %s' % s)
458 s = fh.read()
459 if not s:
460 return None
461 return json.loads(s)
462
463
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200464def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100465 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000466 """
467 Queries a gerrit-on-borg server for changes matching query terms.
468
469 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200470 params: A list of key:value pairs for search parameters, as documented
471 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
472 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000473 first_param: A change identifier
474 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100475 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000476 o_params: A list of additional output specifiers, as documented here:
477 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
478 Returns:
479 A list of json-decoded query results.
480 """
481 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200482 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000483 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200484 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100485 if start:
486 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000487 if limit:
488 path = '%s&n=%d' % (path, limit)
489 if o_params:
490 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700491 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000492
493
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200494def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100495 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000496 """
497 Queries a gerrit-on-borg server for all the changes matching the query terms.
498
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 WARNING: this is unreliable if a change matching the query is modified while
500 this function is being called.
501
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000502 A single query to gerrit-on-borg is limited on the number of results by the
503 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100504 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000505
506 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200507 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000508 limit: Maximum number of requested changes per query.
509 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000511
512 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100513 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000514 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100515 already_returned = set()
516 def at_most_once(cls):
517 for cl in cls:
518 if cl['_number'] not in already_returned:
519 already_returned.add(cl['_number'])
520 yield cl
521
522 start = start or 0
523 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000524 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100525
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000526 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100527 # This will fetch changes[start..start+limit] sorted by most recently
528 # updated. Since the rank of any change in this list can be changed any time
529 # (say user posting comment), subsequent calls may overalp like this:
530 # > initial order ABCDEFGH
531 # query[0..3] => ABC
532 # > E get's updated. New order: EABCDFGH
533 # query[3..6] => CDF # C is a dup
534 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200535 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100536 cur_start)
537 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000538 yield cl
539
540 more_changes = [cl for cl in page if '_more_changes' in cl]
541 if len(more_changes) > 1:
542 raise GerritError(
543 200,
544 'Received %d changes with a _more_changes attribute set but should '
545 'receive at most one.' % len(more_changes))
546 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100547 cur_start += len(page)
548
549 # If we paged through, query again the first page which in most circumstances
550 # will fetch all changes that were modified while this function was run.
551 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200552 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100553 for cl in at_most_once(page):
554 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000555
556
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200557def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100558 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000559 """Initiate a query composed of multiple sets of query parameters."""
560 if not change_list:
561 raise RuntimeError(
562 "MultiQueryChanges requires a list of change numbers/id's")
563 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200564 if params:
565 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000566 if limit:
567 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100568 if start:
569 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000570 if o_params:
571 q.extend(['o=%s' % p for p in o_params])
572 path = 'changes/?%s' % '&'.join(q)
573 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700574 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000575 except GerritError as e:
576 msg = '%s:\n%s' % (e.message, path)
577 raise GerritError(e.http_status, msg)
578 return result
579
580
581def GetGerritFetchUrl(host):
582 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
583 return '%s://%s/' % (GERRIT_PROTOCOL, host)
584
585
586def GetChangePageUrl(host, change_number):
587 """Given a gerrit host name and change number, return change page url."""
588 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
589
590
591def GetChangeUrl(host, change):
592 """Given a gerrit host name and change id, return an url for the change."""
593 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
594
595
596def GetChange(host, change):
597 """Query a gerrit server for information about a single change."""
598 path = 'changes/%s' % change
599 return ReadHttpJsonResponse(CreateHttpConn(host, path))
600
601
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700602def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000603 """Query a gerrit server for extended information about a single change."""
604 path = 'changes/%s/detail' % change
605 if o_params:
606 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700607 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000608
609
agable32978d92016-11-01 12:55:02 -0700610def GetChangeCommit(host, change, revision='current'):
611 """Query a gerrit server for a revision associated with a change."""
612 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
613 return ReadHttpJsonResponse(CreateHttpConn(host, path))
614
615
szager@chromium.orgb4696232013-10-16 19:45:35 +0000616def GetChangeCurrentRevision(host, change):
617 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200618 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000619
620
621def GetChangeRevisions(host, change):
622 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200623 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000624
625
626def GetChangeReview(host, change, revision=None):
627 """Get the current review information for a change."""
628 if not revision:
629 jmsg = GetChangeRevisions(host, change)
630 if not jmsg:
631 return None
632 elif len(jmsg) > 1:
633 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
634 revision = jmsg[0]['current_revision']
635 path = 'changes/%s/revisions/%s/review'
636 return ReadHttpJsonResponse(CreateHttpConn(host, path))
637
638
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700639def GetChangeComments(host, change):
640 """Get the line- and file-level comments on a change."""
641 path = 'changes/%s/comments' % change
642 return ReadHttpJsonResponse(CreateHttpConn(host, path))
643
644
szager@chromium.orgb4696232013-10-16 19:45:35 +0000645def AbandonChange(host, change, msg=''):
646 """Abandon a gerrit change."""
647 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000648 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000649 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700650 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000651
652
653def RestoreChange(host, change, msg=''):
654 """Restore a previously abandoned change."""
655 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000656 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000657 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700658 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000659
660
661def SubmitChange(host, change, wait_for_merge=True):
662 """Submits a gerrit change via Gerrit."""
663 path = 'changes/%s/submit' % change
664 body = {'wait_for_merge': wait_for_merge}
665 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700666 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000667
668
dsansomee2d6fd92016-09-08 00:10:47 -0700669def HasPendingChangeEdit(host, change):
670 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
671 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700672 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700673 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700674 # 204 No Content means no pending change.
675 if e.http_status == 204:
676 return False
677 raise
678 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700679
680
681def DeletePendingChangeEdit(host, change):
682 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700683 # On success, gerrit returns status 204; if the edit was already deleted it
684 # returns 404. Anything else is an error.
685 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700686
687
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100688def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000689 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700690 assert notify in ('ALL', 'NONE')
691 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700692 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700693 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000694 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700695 ReadHttpResponse(conn, accept_statuses=[200, 204])
696 except GerritError as e:
697 raise GerritError(
698 e.http_status,
699 'Received unexpected http status while editing message '
700 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000701
702
szager@chromium.orgb4696232013-10-16 19:45:35 +0000703def GetReviewers(host, change):
704 """Get information about all reviewers attached to a change."""
705 path = 'changes/%s/reviewers' % change
706 return ReadHttpJsonResponse(CreateHttpConn(host, path))
707
708
709def GetReview(host, change, revision):
710 """Get review information about a specific revision of a change."""
711 path = 'changes/%s/revisions/%s/review' % (change, revision)
712 return ReadHttpJsonResponse(CreateHttpConn(host, path))
713
714
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700715def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
716 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000717 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700718 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800719 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200720 if not change:
721 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700722 reviewers = frozenset(reviewers or [])
723 ccs = frozenset(ccs or [])
724 path = 'changes/%s/revisions/current/review' % change
725
726 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800727 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700728 'reviewers': [],
729 'notify': 'ALL' if notify else 'NONE',
730 }
731 for r in sorted(reviewers | ccs):
732 state = 'REVIEWER' if r in reviewers else 'CC'
733 body['reviewers'].append({
734 'reviewer': r,
735 'state': state,
736 'notify': 'NONE', # We handled `notify` argument above.
737 })
738
739 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
740 # Gerrit will return 400 if one or more of the requested reviewers are
741 # unprocessable. We read the response object to see which were rejected,
742 # warn about them, and retry with the remainder.
743 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
744
745 errored = set()
746 for result in resp.get('reviewers', {}).itervalues():
747 r = result.get('input')
748 state = 'REVIEWER' if r in reviewers else 'CC'
749 if result.get('error'):
750 errored.add(r)
751 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
752 if errored:
753 # Try again, adding only those that didn't fail, and only accepting 200.
754 AddReviewers(host, change, reviewers=(reviewers-errored),
755 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000756
757
758def RemoveReviewers(host, change, remove=None):
759 """Remove reveiewers from a change."""
760 if not remove:
761 return
762 if isinstance(remove, basestring):
763 remove = (remove,)
764 for r in remove:
765 path = 'changes/%s/reviewers/%s' % (change, r)
766 conn = CreateHttpConn(host, path, reqtype='DELETE')
767 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700768 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000769 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000770 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700771 e.http_status,
772 'Received unexpected http status while deleting reviewer "%s" '
773 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000774
775
Aaron Gable636b13f2017-07-14 10:42:48 -0700776def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000777 """Set labels and/or add a message to a code review."""
778 if not msg and not labels:
779 return
780 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800781 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000782 if msg:
783 body['message'] = msg
784 if labels:
785 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700786 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700787 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700788 if ready:
789 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000790 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
791 response = ReadHttpJsonResponse(conn)
792 if labels:
793 for key, val in labels.iteritems():
794 if ('labels' not in response or key not in response['labels'] or
795 int(response['labels'][key] != int(val))):
796 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
797 key, change))
798
799
800def ResetReviewLabels(host, change, label, value='0', message=None,
801 notify=None):
802 """Reset the value of a given label for all reviewers on a change."""
803 # This is tricky, because we want to work on the "current revision", but
804 # there's always the risk that "current revision" will change in between
805 # API calls. So, we check "current revision" at the beginning and end; if
806 # it has changed, raise an exception.
807 jmsg = GetChangeCurrentRevision(host, change)
808 if not jmsg:
809 raise GerritError(
810 200, 'Could not get review information for change "%s"' % change)
811 value = str(value)
812 revision = jmsg[0]['current_revision']
813 path = 'changes/%s/revisions/%s/review' % (change, revision)
814 message = message or (
815 '%s label set to %s programmatically.' % (label, value))
816 jmsg = GetReview(host, change, revision)
817 if not jmsg:
818 raise GerritError(200, 'Could not get review information for revison %s '
819 'of change %s' % (revision, change))
820 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
821 if str(review.get('value', value)) != value:
822 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800823 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000824 'message': message,
825 'labels': {label: value},
826 'on_behalf_of': review['_account_id'],
827 }
828 if notify:
829 body['notify'] = notify
830 conn = CreateHttpConn(
831 host, path, reqtype='POST', body=body)
832 response = ReadHttpJsonResponse(conn)
833 if str(response['labels'][label]) != value:
834 username = review.get('email', jmsg.get('name', ''))
835 raise GerritError(200, 'Unable to set %s label for user "%s"'
836 ' on change %s.' % (label, username, change))
837 jmsg = GetChangeCurrentRevision(host, change)
838 if not jmsg:
839 raise GerritError(
840 200, 'Could not get review information for change "%s"' % change)
841 elif jmsg[0]['current_revision'] != revision:
842 raise GerritError(200, 'While resetting labels on change "%s", '
843 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800844
845
dimu833c94c2017-01-18 17:36:15 -0800846def CreateGerritBranch(host, project, branch, commit):
847 """
848 Create a new branch from given project and commit
849 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
850
851 Returns:
852 A JSON with 'ref' key
853 """
854 path = 'projects/%s/branches/%s' % (project, branch)
855 body = {'revision': commit}
856 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700857 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800858 if response:
859 return response
860 raise GerritError(200, 'Unable to create gerrit branch')
861
862
863def GetGerritBranch(host, project, branch):
864 """
865 Get a branch from given project and commit
866 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
867
868 Returns:
869 A JSON object with 'revision' key
870 """
871 path = 'projects/%s/branches/%s' % (project, branch)
872 conn = CreateHttpConn(host, path, reqtype='GET')
873 response = ReadHttpJsonResponse(conn)
874 if response:
875 return response
876 raise GerritError(200, 'Unable to get gerrit branch')
877
878
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100879def GetAccountDetails(host, account_id='self'):
880 """Returns details of the account.
881
882 If account_id is not given, uses magic value 'self' which corresponds to
883 whichever account user is authenticating as.
884
885 Documentation:
886 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
887 """
888 if account_id != 'self':
889 account_id = int(account_id)
890 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
891 return ReadHttpJsonResponse(conn)
892
893
Nick Carter8692b182017-11-06 16:30:38 -0800894def PercentEncodeForGitRef(original):
895 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
896
897 The encoding used is based on but stricter than URL encoding (Section 2.1
898 of RFC 3986). The only non-escaped characters are alphanumerics, and
899 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
900 'PLUS SIGN' (U+002B).
901
902 For more information, see the Gerrit docs here:
903
904 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
905 """
906 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
907 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
908
909 # spaces are not allowed in git refs; gerrit will interpret either '_' or
910 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
911 return encoded.replace(' ', '_')
912
913
Dan Jacques8d11e482016-11-15 14:25:56 -0800914@contextlib.contextmanager
915def tempdir():
916 tdir = None
917 try:
918 tdir = tempfile.mkdtemp(suffix='gerrit_util')
919 yield tdir
920 finally:
921 if tdir:
922 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000923
924
925def ChangeIdentifier(project, change_number):
926 """Returns change identifier "project~number" suitable for |chagne| arg of
927 this module API.
928
929 Such format is allows for more efficient Gerrit routing of HTTP requests,
930 comparing to specifying just change_number.
931 """
932 assert int(change_number)
933 return '%s~%s' % (urllib.quote(project, safe=''), change_number)