blob: 6d45ab781101a48420a5e0f3bdbdedb8981f40d5 [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 Boren05263352018-09-18 16:54:45 +0000203 login, secret_token = value.split('=', 1)
204 gitcookies[domain] = (login, secret_token)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000205 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100206 LOGGER.warning(exc)
Eric Boren05263352018-09-18 16:54:45 +0000207
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000208 return gitcookies
209
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100210 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000211 for domain, creds in self.gitcookies.iteritems():
212 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100213 return (creds[0], None, creds[1])
214 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000215
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100216 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700217 a = self._get_auth_for_host(host)
218 if a:
Eric Boren05263352018-09-18 16:54:45 +0000219 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000220 return None
221
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100222 def get_auth_email(self, host):
223 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700224 a = self._get_auth_for_host(host)
225 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100226 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700227 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100228 # login typically looks like 'git-xxx.example.com'
229 if not login.startswith('git-') or '.' not in login:
230 return None
231 username, domain = login[len('git-'):].split('.', 1)
232 return '%s@%s' % (username, domain)
233
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100234
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000235# Backwards compatibility just in case somebody imports this outside of
236# depot_tools.
237NetrcAuthenticator = CookiesAuthenticator
238
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000239
240class GceAuthenticator(Authenticator):
241 """Authenticator implementation that uses GCE metadata service for token.
242 """
243
244 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700245 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
246 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000247 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
248
249 _cache_is_gce = None
250 _token_cache = None
251 _token_expiration = None
252
253 @classmethod
254 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500255 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
256 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000257 if cls._cache_is_gce is None:
258 cls._cache_is_gce = cls._test_is_gce()
259 return cls._cache_is_gce
260
261 @classmethod
262 def _test_is_gce(cls):
263 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
264 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100265 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100266 except (socket.error, httplib2.ServerNotFoundError,
267 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000268 # Could not resolve URL.
269 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100270 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000271
272 @staticmethod
273 def _get(url, **kwargs):
274 next_delay_sec = 1
275 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000276 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700277 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100278 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000279 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
280 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100281 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000282
Aaron Gable92e9f382017-12-07 11:47:41 -0800283 # Retry server error status codes.
284 LOGGER.warn('Encountered server error')
285 if TRY_LIMIT - i > 1:
286 LOGGER.info('Will retry in %d seconds (%d more times)...',
287 next_delay_sec, TRY_LIMIT - i - 1)
288 time.sleep(next_delay_sec)
289 next_delay_sec *= 2
290
291
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000292 @classmethod
293 def _get_token_dict(cls):
294 if cls._token_cache:
295 # If it expires within 25 seconds, refresh.
296 if cls._token_expiration < time.time() - 25:
297 return cls._token_cache
298
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100299 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000300 if resp.status != httplib.OK:
301 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100302 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
304 return cls._token_cache
305
306 def get_auth_header(self, _host):
307 token_dict = self._get_token_dict()
308 if not token_dict:
309 return None
310 return '%(token_type)s %(access_token)s' % token_dict
311
312
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700313class LuciContextAuthenticator(Authenticator):
314 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
315 """
316
317 @staticmethod
318 def is_luci():
319 return auth.has_luci_context_local_auth()
320
321 def __init__(self):
322 self._access_token = None
323 self._ensure_fresh()
324
325 def _ensure_fresh(self):
326 if not self._access_token or self._access_token.needs_refresh():
327 self._access_token = auth.get_luci_context_access_token(
328 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
329
330 def get_auth_header(self, _host):
331 self._ensure_fresh()
332 return 'Bearer %s' % self._access_token.token
333
334
szager@chromium.orgb4696232013-10-16 19:45:35 +0000335def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
336 """Opens an https connection to a gerrit service, and sends a request."""
337 headers = headers or {}
338 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000339
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700340 a = Authenticator.get().get_auth_header(bare_host)
341 if a:
342 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000343 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000344 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000345
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800346 url = path
347 if not url.startswith('/'):
348 url = '/' + url
349 if 'Authorization' in headers and not url.startswith('/a/'):
350 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000351
szager@chromium.orgb4696232013-10-16 19:45:35 +0000352 if body:
353 body = json.JSONEncoder().encode(body)
354 headers.setdefault('Content-Type', 'application/json')
355 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000356 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000357 for key, val in headers.iteritems():
358 if key == 'Authorization':
359 val = 'HIDDEN'
360 LOGGER.debug('%s: %s' % (key, val))
361 if body:
362 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700363 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000364 # HACK: httplib.Http has no such attribute; we store req_host here for later
365 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000366 conn.req_host = host
367 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100368 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000369 'method': reqtype,
370 'headers': headers,
371 'body': body,
372 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000373 return conn
374
375
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700376def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000377 """Reads an http response from a connection into a string buffer.
378
379 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100380 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700381 accept_statuses: Treat any of these statuses as success. Default: [200]
382 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000383 Returns: A string buffer containing the connection's reply.
384 """
Steve Kobes56117722018-09-13 18:18:35 +0000385 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000386 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100387 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000388
389 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100390 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000391 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
392 www_authenticate):
393 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
394 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700395 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000396 'has credentials for %s' % host)
397 raise GerritAuthenticationError(response.status, reason)
398
szager@chromium.orgb4696232013-10-16 19:45:35 +0000399 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000400 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700401 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000402 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700403 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100404 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100405 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700406 # If 404 was in accept_statuses, then it's expected that the file might
407 # not exist, so don't return the gitiles error page because that's not the
408 # "content" that was actually requested.
409 if response.status == 404:
410 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000411 break
412 # A status >=500 is assumed to be a possible transient error; retry.
413 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andy Perelsona06cd092018-09-24 21:29:57 +0000414 LOGGER.warn('A transient error occurred while querying %s:\n'
415 '%s %s %s\n'
416 '%s %d %s',
417 conn.req_host, conn.req_params['method'],
418 conn.req_params['uri'],
419 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000420 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800421 LOGGER.info('Will retry in %d seconds (%d more times)...',
422 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000423 time.sleep(sleep_time)
424 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700425 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200426 if response.status in (401, 403):
427 print('Your Gerrit credentials might be misconfigured. Try: \n'
428 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100429 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000430 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100431 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000432
433
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700434def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000435 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700436 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000437 # The first line of the response should always be: )]}'
438 s = fh.readline()
439 if s and s.rstrip() != ")]}'":
440 raise GerritError(200, 'Unexpected json output: %s' % s)
441 s = fh.read()
442 if not s:
443 return None
444 return json.loads(s)
445
446
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200447def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100448 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000449 """
450 Queries a gerrit-on-borg server for changes matching query terms.
451
452 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200453 params: A list of key:value pairs for search parameters, as documented
454 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
455 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000456 first_param: A change identifier
457 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100458 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000459 o_params: A list of additional output specifiers, as documented here:
460 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
461 Returns:
462 A list of json-decoded query results.
463 """
464 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200465 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000466 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200467 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100468 if start:
469 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000470 if limit:
471 path = '%s&n=%d' % (path, limit)
472 if o_params:
473 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700474 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000475
476
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200477def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100478 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000479 """
480 Queries a gerrit-on-borg server for all the changes matching the query terms.
481
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100482 WARNING: this is unreliable if a change matching the query is modified while
483 this function is being called.
484
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000485 A single query to gerrit-on-borg is limited on the number of results by the
486 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100487 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000488
489 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200490 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000491 limit: Maximum number of requested changes per query.
492 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100493 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000494
495 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100496 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000497 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100498 already_returned = set()
499 def at_most_once(cls):
500 for cl in cls:
501 if cl['_number'] not in already_returned:
502 already_returned.add(cl['_number'])
503 yield cl
504
505 start = start or 0
506 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000507 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100508
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000509 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 # This will fetch changes[start..start+limit] sorted by most recently
511 # updated. Since the rank of any change in this list can be changed any time
512 # (say user posting comment), subsequent calls may overalp like this:
513 # > initial order ABCDEFGH
514 # query[0..3] => ABC
515 # > E get's updated. New order: EABCDFGH
516 # query[3..6] => CDF # C is a dup
517 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200518 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100519 cur_start)
520 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000521 yield cl
522
523 more_changes = [cl for cl in page if '_more_changes' in cl]
524 if len(more_changes) > 1:
525 raise GerritError(
526 200,
527 'Received %d changes with a _more_changes attribute set but should '
528 'receive at most one.' % len(more_changes))
529 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100530 cur_start += len(page)
531
532 # If we paged through, query again the first page which in most circumstances
533 # will fetch all changes that were modified while this function was run.
534 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200535 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100536 for cl in at_most_once(page):
537 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000538
539
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200540def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100541 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000542 """Initiate a query composed of multiple sets of query parameters."""
543 if not change_list:
544 raise RuntimeError(
545 "MultiQueryChanges requires a list of change numbers/id's")
546 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200547 if params:
548 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000549 if limit:
550 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100551 if start:
552 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000553 if o_params:
554 q.extend(['o=%s' % p for p in o_params])
555 path = 'changes/?%s' % '&'.join(q)
556 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700557 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000558 except GerritError as e:
559 msg = '%s:\n%s' % (e.message, path)
560 raise GerritError(e.http_status, msg)
561 return result
562
563
564def GetGerritFetchUrl(host):
565 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
566 return '%s://%s/' % (GERRIT_PROTOCOL, host)
567
568
569def GetChangePageUrl(host, change_number):
570 """Given a gerrit host name and change number, return change page url."""
571 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
572
573
574def GetChangeUrl(host, change):
575 """Given a gerrit host name and change id, return an url for the change."""
576 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
577
578
579def GetChange(host, change):
580 """Query a gerrit server for information about a single change."""
581 path = 'changes/%s' % change
582 return ReadHttpJsonResponse(CreateHttpConn(host, path))
583
584
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700585def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000586 """Query a gerrit server for extended information about a single change."""
587 path = 'changes/%s/detail' % change
588 if o_params:
589 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700590 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000591
592
agable32978d92016-11-01 12:55:02 -0700593def GetChangeCommit(host, change, revision='current'):
594 """Query a gerrit server for a revision associated with a change."""
595 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
596 return ReadHttpJsonResponse(CreateHttpConn(host, path))
597
598
szager@chromium.orgb4696232013-10-16 19:45:35 +0000599def GetChangeCurrentRevision(host, change):
600 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200601 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000602
603
604def GetChangeRevisions(host, change):
605 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200606 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000607
608
609def GetChangeReview(host, change, revision=None):
610 """Get the current review information for a change."""
611 if not revision:
612 jmsg = GetChangeRevisions(host, change)
613 if not jmsg:
614 return None
615 elif len(jmsg) > 1:
616 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
617 revision = jmsg[0]['current_revision']
618 path = 'changes/%s/revisions/%s/review'
619 return ReadHttpJsonResponse(CreateHttpConn(host, path))
620
621
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700622def GetChangeComments(host, change):
623 """Get the line- and file-level comments on a change."""
624 path = 'changes/%s/comments' % change
625 return ReadHttpJsonResponse(CreateHttpConn(host, path))
626
627
szager@chromium.orgb4696232013-10-16 19:45:35 +0000628def AbandonChange(host, change, msg=''):
629 """Abandon a gerrit change."""
630 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000631 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000632 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700633 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000634
635
636def RestoreChange(host, change, msg=''):
637 """Restore a previously abandoned change."""
638 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000639 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000640 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700641 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000642
643
644def SubmitChange(host, change, wait_for_merge=True):
645 """Submits a gerrit change via Gerrit."""
646 path = 'changes/%s/submit' % change
647 body = {'wait_for_merge': wait_for_merge}
648 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700649 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000650
651
dsansomee2d6fd92016-09-08 00:10:47 -0700652def HasPendingChangeEdit(host, change):
653 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
654 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700655 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700656 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700657 # 204 No Content means no pending change.
658 if e.http_status == 204:
659 return False
660 raise
661 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700662
663
664def DeletePendingChangeEdit(host, change):
665 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700666 # On success, gerrit returns status 204; if the edit was already deleted it
667 # returns 404. Anything else is an error.
668 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700669
670
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100671def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000672 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700673 assert notify in ('ALL', 'NONE')
674 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700675 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700676 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000677 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700678 ReadHttpResponse(conn, accept_statuses=[200, 204])
679 except GerritError as e:
680 raise GerritError(
681 e.http_status,
682 'Received unexpected http status while editing message '
683 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000684
685
szager@chromium.orgb4696232013-10-16 19:45:35 +0000686def GetReviewers(host, change):
687 """Get information about all reviewers attached to a change."""
688 path = 'changes/%s/reviewers' % change
689 return ReadHttpJsonResponse(CreateHttpConn(host, path))
690
691
692def GetReview(host, change, revision):
693 """Get review information about a specific revision of a change."""
694 path = 'changes/%s/revisions/%s/review' % (change, revision)
695 return ReadHttpJsonResponse(CreateHttpConn(host, path))
696
697
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700698def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
699 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000700 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700701 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800702 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200703 if not change:
704 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700705 reviewers = frozenset(reviewers or [])
706 ccs = frozenset(ccs or [])
707 path = 'changes/%s/revisions/current/review' % change
708
709 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800710 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700711 'reviewers': [],
712 'notify': 'ALL' if notify else 'NONE',
713 }
714 for r in sorted(reviewers | ccs):
715 state = 'REVIEWER' if r in reviewers else 'CC'
716 body['reviewers'].append({
717 'reviewer': r,
718 'state': state,
719 'notify': 'NONE', # We handled `notify` argument above.
720 })
721
722 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
723 # Gerrit will return 400 if one or more of the requested reviewers are
724 # unprocessable. We read the response object to see which were rejected,
725 # warn about them, and retry with the remainder.
726 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
727
728 errored = set()
729 for result in resp.get('reviewers', {}).itervalues():
730 r = result.get('input')
731 state = 'REVIEWER' if r in reviewers else 'CC'
732 if result.get('error'):
733 errored.add(r)
734 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
735 if errored:
736 # Try again, adding only those that didn't fail, and only accepting 200.
737 AddReviewers(host, change, reviewers=(reviewers-errored),
738 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000739
740
741def RemoveReviewers(host, change, remove=None):
742 """Remove reveiewers from a change."""
743 if not remove:
744 return
745 if isinstance(remove, basestring):
746 remove = (remove,)
747 for r in remove:
748 path = 'changes/%s/reviewers/%s' % (change, r)
749 conn = CreateHttpConn(host, path, reqtype='DELETE')
750 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700751 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000752 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000753 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700754 e.http_status,
755 'Received unexpected http status while deleting reviewer "%s" '
756 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000757
758
Aaron Gable636b13f2017-07-14 10:42:48 -0700759def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000760 """Set labels and/or add a message to a code review."""
761 if not msg and not labels:
762 return
763 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800764 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000765 if msg:
766 body['message'] = msg
767 if labels:
768 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700769 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700770 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700771 if ready:
772 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000773 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
774 response = ReadHttpJsonResponse(conn)
775 if labels:
776 for key, val in labels.iteritems():
777 if ('labels' not in response or key not in response['labels'] or
778 int(response['labels'][key] != int(val))):
779 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
780 key, change))
781
782
783def ResetReviewLabels(host, change, label, value='0', message=None,
784 notify=None):
785 """Reset the value of a given label for all reviewers on a change."""
786 # This is tricky, because we want to work on the "current revision", but
787 # there's always the risk that "current revision" will change in between
788 # API calls. So, we check "current revision" at the beginning and end; if
789 # it has changed, raise an exception.
790 jmsg = GetChangeCurrentRevision(host, change)
791 if not jmsg:
792 raise GerritError(
793 200, 'Could not get review information for change "%s"' % change)
794 value = str(value)
795 revision = jmsg[0]['current_revision']
796 path = 'changes/%s/revisions/%s/review' % (change, revision)
797 message = message or (
798 '%s label set to %s programmatically.' % (label, value))
799 jmsg = GetReview(host, change, revision)
800 if not jmsg:
801 raise GerritError(200, 'Could not get review information for revison %s '
802 'of change %s' % (revision, change))
803 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
804 if str(review.get('value', value)) != value:
805 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800806 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000807 'message': message,
808 'labels': {label: value},
809 'on_behalf_of': review['_account_id'],
810 }
811 if notify:
812 body['notify'] = notify
813 conn = CreateHttpConn(
814 host, path, reqtype='POST', body=body)
815 response = ReadHttpJsonResponse(conn)
816 if str(response['labels'][label]) != value:
817 username = review.get('email', jmsg.get('name', ''))
818 raise GerritError(200, 'Unable to set %s label for user "%s"'
819 ' on change %s.' % (label, username, change))
820 jmsg = GetChangeCurrentRevision(host, change)
821 if not jmsg:
822 raise GerritError(
823 200, 'Could not get review information for change "%s"' % change)
824 elif jmsg[0]['current_revision'] != revision:
825 raise GerritError(200, 'While resetting labels on change "%s", '
826 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800827
828
dimu833c94c2017-01-18 17:36:15 -0800829def CreateGerritBranch(host, project, branch, commit):
830 """
831 Create a new branch from given project and commit
832 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
833
834 Returns:
835 A JSON with 'ref' key
836 """
837 path = 'projects/%s/branches/%s' % (project, branch)
838 body = {'revision': commit}
839 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700840 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800841 if response:
842 return response
843 raise GerritError(200, 'Unable to create gerrit branch')
844
845
846def GetGerritBranch(host, project, branch):
847 """
848 Get a branch from given project and commit
849 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
850
851 Returns:
852 A JSON object with 'revision' key
853 """
854 path = 'projects/%s/branches/%s' % (project, branch)
855 conn = CreateHttpConn(host, path, reqtype='GET')
856 response = ReadHttpJsonResponse(conn)
857 if response:
858 return response
859 raise GerritError(200, 'Unable to get gerrit branch')
860
861
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100862def GetAccountDetails(host, account_id='self'):
863 """Returns details of the account.
864
865 If account_id is not given, uses magic value 'self' which corresponds to
866 whichever account user is authenticating as.
867
868 Documentation:
869 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
870 """
871 if account_id != 'self':
872 account_id = int(account_id)
873 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
874 return ReadHttpJsonResponse(conn)
875
876
Nick Carter8692b182017-11-06 16:30:38 -0800877def PercentEncodeForGitRef(original):
878 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
879
880 The encoding used is based on but stricter than URL encoding (Section 2.1
881 of RFC 3986). The only non-escaped characters are alphanumerics, and
882 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
883 'PLUS SIGN' (U+002B).
884
885 For more information, see the Gerrit docs here:
886
887 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
888 """
889 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
890 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
891
892 # spaces are not allowed in git refs; gerrit will interpret either '_' or
893 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
894 return encoded.replace(' ', '_')
895
896
Dan Jacques8d11e482016-11-15 14:25:56 -0800897@contextlib.contextmanager
898def tempdir():
899 tdir = None
900 try:
901 tdir = tempfile.mkdtemp(suffix='gerrit_util')
902 yield tdir
903 finally:
904 if tdir:
905 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000906
907
908def ChangeIdentifier(project, change_number):
909 """Returns change identifier "project~number" suitable for |chagne| arg of
910 this module API.
911
912 Such format is allows for more efficient Gerrit routing of HTTP requests,
913 comparing to specifying just change_number.
914 """
915 assert int(change_number)
916 return '%s~%s' % (urllib.quote(project, safe=''), change_number)