blob: d5780adce211d7fb5d5c4dea1c23c5bf10eb7fb6 [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()
Aaron Gable92e9f382017-12-07 11:47:41 -080035# With a starting sleep time of 1 second, 2^n exponential backoff, and six
36# total tries, the sleep time between the first and last tries will be 31s.
37TRY_LIMIT = 6
szager@chromium.orgb4696232013-10-16 19:45:35 +000038
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000039
szager@chromium.orgb4696232013-10-16 19:45:35 +000040# Controls the transport protocol used to communicate with gerrit.
41# This is parameterized primarily to enable GerritTestCase.
42GERRIT_PROTOCOL = 'https'
43
44
45class GerritError(Exception):
46 """Exception class for errors commuicating with the gerrit-on-borg service."""
47 def __init__(self, http_status, *args, **kwargs):
48 super(GerritError, self).__init__(*args, **kwargs)
49 self.http_status = http_status
50 self.message = '(%d) %s' % (self.http_status, self.message)
51
52
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000053class GerritAuthenticationError(GerritError):
54 """Exception class for authentication errors during Gerrit communication."""
55
56
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020057def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000058 """Encodes query parameters in the key:val[+key:val...] format specified here:
59
60 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
61 """
62 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020063 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000064 return '+'.join(q)
65
66
Aaron Gabled2db5a22017-03-24 14:14:15 -070067def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000068 if protocol is None:
69 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010070 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070071 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000072 else:
73 raise RuntimeError(
74 "Don't know how to work with protocol '%s'" % protocol)
75
76
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000077class Authenticator(object):
78 """Base authenticator class for authenticator implementations to subclass."""
79
80 def get_auth_header(self, host):
81 raise NotImplementedError()
82
83 @staticmethod
84 def get():
85 """Returns: (Authenticator) The identified Authenticator to use.
86
87 Probes the local system and its environment and identifies the
88 Authenticator instance to use.
89 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070090 # LUCI Context takes priority since it's normally present only on bots,
91 # which then must use it.
92 if LuciContextAuthenticator.is_luci():
93 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000094 if GceAuthenticator.is_gce():
95 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000096 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000097
98
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000099class CookiesAuthenticator(Authenticator):
100 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
101
102 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000103 """
104
105 def __init__(self):
106 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000107 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000108
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000109 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200110 def get_new_password_url(cls, host):
111 assert not host.startswith('http')
112 # Assume *.googlesource.com pattern.
113 parts = host.split('.')
114 if not parts[0].endswith('-review'):
115 parts[0] += '-review'
116 return 'https://%s/new-password' % ('.'.join(parts))
117
118 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000119 def get_new_password_message(cls, host):
120 assert not host.startswith('http')
121 # Assume *.googlesource.com pattern.
122 parts = host.split('.')
123 if not parts[0].endswith('-review'):
124 parts[0] += '-review'
125 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100126 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000127
128 @classmethod
129 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000130 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000131 return os.path.expanduser(os.path.join('~', path))
132
133 @classmethod
134 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800135 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000136 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000137 if not os.path.exists(path):
138 return netrc.netrc(os.devnull)
139
140 st = os.stat(path)
141 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
142 print >> sys.stderr, (
143 'WARNING: netrc file %s cannot be used because its file '
144 'permissions are insecure. netrc file permissions should be '
145 '600.' % path)
146 with open(path) as fd:
147 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800148
149 # Load the '.netrc' file. We strip comments from it because processing them
150 # can trigger a bug in Windows. See crbug.com/664664.
151 content = '\n'.join(l for l in content.splitlines()
152 if l.strip() and not l.strip().startswith('#'))
153 with tempdir() as tdir:
154 netrc_path = os.path.join(tdir, 'netrc')
155 with open(netrc_path, 'w') as fd:
156 fd.write(content)
157 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
158 return cls._get_netrc_from_path(netrc_path)
159
160 @classmethod
161 def _get_netrc_from_path(cls, path):
162 try:
163 return netrc.netrc(path)
164 except IOError:
165 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
166 return netrc.netrc(os.devnull)
167 except netrc.NetrcParseError as e:
168 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
169 'parsing error: %s' % (path, e))
170 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000171
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000172 @classmethod
173 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500174 if os.getenv('GIT_COOKIES_PATH'):
175 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800176 try:
177 return subprocess2.check_output(
178 ['git', 'config', '--path', 'http.cookiefile']).strip()
179 except subprocess2.CalledProcessError:
180 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000181
182 @classmethod
183 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000184 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000185 path = cls.get_gitcookies_path()
186 if not os.path.exists(path):
187 return gitcookies
188
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000189 try:
190 f = open(path, 'rb')
191 except IOError:
192 return gitcookies
193
194 with f:
195 for line in f:
196 try:
197 fields = line.strip().split('\t')
198 if line.strip().startswith('#') or len(fields) != 7:
199 continue
200 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
201 if xpath == '/' and key == 'o':
202 login, secret_token = value.split('=', 1)
203 gitcookies[domain] = (login, secret_token)
204 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100205 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000206
207 return gitcookies
208
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100209 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000210 for domain, creds in self.gitcookies.iteritems():
211 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100212 return (creds[0], None, creds[1])
213 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000214
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100215 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700216 a = self._get_auth_for_host(host)
217 if a:
218 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000219 return None
220
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100221 def get_auth_email(self, host):
222 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700223 a = self._get_auth_for_host(host)
224 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100225 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700226 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100227 # login typically looks like 'git-xxx.example.com'
228 if not login.startswith('git-') or '.' not in login:
229 return None
230 username, domain = login[len('git-'):].split('.', 1)
231 return '%s@%s' % (username, domain)
232
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100233
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000234# Backwards compatibility just in case somebody imports this outside of
235# depot_tools.
236NetrcAuthenticator = CookiesAuthenticator
237
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000238
239class GceAuthenticator(Authenticator):
240 """Authenticator implementation that uses GCE metadata service for token.
241 """
242
243 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700244 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
245 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000246 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
247
248 _cache_is_gce = None
249 _token_cache = None
250 _token_expiration = None
251
252 @classmethod
253 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500254 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
255 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000256 if cls._cache_is_gce is None:
257 cls._cache_is_gce = cls._test_is_gce()
258 return cls._cache_is_gce
259
260 @classmethod
261 def _test_is_gce(cls):
262 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
263 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100264 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100265 except (socket.error, httplib2.ServerNotFoundError,
266 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000267 # Could not resolve URL.
268 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100269 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000270
271 @staticmethod
272 def _get(url, **kwargs):
273 next_delay_sec = 1
274 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000275 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700276 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100277 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000278 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
279 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100280 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000281
Aaron Gable92e9f382017-12-07 11:47:41 -0800282 # Retry server error status codes.
283 LOGGER.warn('Encountered server error')
284 if TRY_LIMIT - i > 1:
285 LOGGER.info('Will retry in %d seconds (%d more times)...',
286 next_delay_sec, TRY_LIMIT - i - 1)
287 time.sleep(next_delay_sec)
288 next_delay_sec *= 2
289
290
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000291 @classmethod
292 def _get_token_dict(cls):
293 if cls._token_cache:
294 # If it expires within 25 seconds, refresh.
295 if cls._token_expiration < time.time() - 25:
296 return cls._token_cache
297
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100298 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000299 if resp.status != httplib.OK:
300 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100301 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000302 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
303 return cls._token_cache
304
305 def get_auth_header(self, _host):
306 token_dict = self._get_token_dict()
307 if not token_dict:
308 return None
309 return '%(token_type)s %(access_token)s' % token_dict
310
311
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700312class LuciContextAuthenticator(Authenticator):
313 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
314 """
315
316 @staticmethod
317 def is_luci():
318 return auth.has_luci_context_local_auth()
319
320 def __init__(self):
321 self._access_token = None
322 self._ensure_fresh()
323
324 def _ensure_fresh(self):
325 if not self._access_token or self._access_token.needs_refresh():
326 self._access_token = auth.get_luci_context_access_token(
327 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
328
329 def get_auth_header(self, _host):
330 self._ensure_fresh()
331 return 'Bearer %s' % self._access_token.token
332
333
szager@chromium.orgb4696232013-10-16 19:45:35 +0000334def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
335 """Opens an https connection to a gerrit service, and sends a request."""
336 headers = headers or {}
337 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000338
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700339 a = Authenticator.get().get_auth_header(bare_host)
340 if a:
341 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000342 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000343 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000344
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800345 url = path
346 if not url.startswith('/'):
347 url = '/' + url
348 if 'Authorization' in headers and not url.startswith('/a/'):
349 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000350
szager@chromium.orgb4696232013-10-16 19:45:35 +0000351 if body:
352 body = json.JSONEncoder().encode(body)
353 headers.setdefault('Content-Type', 'application/json')
354 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000355 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000356 for key, val in headers.iteritems():
357 if key == 'Authorization':
358 val = 'HIDDEN'
359 LOGGER.debug('%s: %s' % (key, val))
360 if body:
361 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700362 conn = GetConnectionObject()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000363 conn.req_host = host
364 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100365 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000366 'method': reqtype,
367 'headers': headers,
368 'body': body,
369 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000370 return conn
371
372
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700373def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000374 """Reads an http response from a connection into a string buffer.
375
376 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100377 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700378 accept_statuses: Treat any of these statuses as success. Default: [200]
379 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000380 Returns: A string buffer containing the connection's reply.
381 """
Aaron Gable92e9f382017-12-07 11:47:41 -0800382 sleep_time = 1
szager@chromium.orgb4696232013-10-16 19:45:35 +0000383 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100384 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000385
386 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100387 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000388 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
389 www_authenticate):
390 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
391 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700392 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000393 'has credentials for %s' % host)
394 raise GerritAuthenticationError(response.status, reason)
395
szager@chromium.orgb4696232013-10-16 19:45:35 +0000396 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000397 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700398 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000399 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700400 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100401 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100402 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700403 # If 404 was in accept_statuses, then it's expected that the file might
404 # not exist, so don't return the gitiles error page because that's not the
405 # "content" that was actually requested.
406 if response.status == 404:
407 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000408 break
409 # A status >=500 is assumed to be a possible transient error; retry.
410 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100411 LOGGER.warn('A transient error occurred while querying %s:\n'
412 '%s %s %s\n'
413 '%s %d %s',
Aaron Gable20d2cbb2017-04-25 15:04:05 -0700414 conn.req_host, conn.req_params['method'],
415 conn.req_params['uri'],
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100416 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000417 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800418 LOGGER.info('Will retry in %d seconds (%d more times)...',
419 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000420 time.sleep(sleep_time)
421 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700422 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200423 if response.status in (401, 403):
424 print('Your Gerrit credentials might be misconfigured. Try: \n'
425 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100426 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000427 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100428 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000429
430
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700431def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000432 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700433 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000434 # The first line of the response should always be: )]}'
435 s = fh.readline()
436 if s and s.rstrip() != ")]}'":
437 raise GerritError(200, 'Unexpected json output: %s' % s)
438 s = fh.read()
439 if not s:
440 return None
441 return json.loads(s)
442
443
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200444def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100445 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000446 """
447 Queries a gerrit-on-borg server for changes matching query terms.
448
449 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200450 params: A list of key:value pairs for search parameters, as documented
451 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
452 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000453 first_param: A change identifier
454 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100455 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000456 o_params: A list of additional output specifiers, as documented here:
457 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
458 Returns:
459 A list of json-decoded query results.
460 """
461 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200462 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000463 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200464 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100465 if start:
466 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000467 if limit:
468 path = '%s&n=%d' % (path, limit)
469 if o_params:
470 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700471 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000472
473
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200474def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100475 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000476 """
477 Queries a gerrit-on-borg server for all the changes matching the query terms.
478
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100479 WARNING: this is unreliable if a change matching the query is modified while
480 this function is being called.
481
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000482 A single query to gerrit-on-borg is limited on the number of results by the
483 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100484 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000485
486 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200487 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000488 limit: Maximum number of requested changes per query.
489 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100490 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000491
492 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100493 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000494 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100495 already_returned = set()
496 def at_most_once(cls):
497 for cl in cls:
498 if cl['_number'] not in already_returned:
499 already_returned.add(cl['_number'])
500 yield cl
501
502 start = start or 0
503 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000504 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100505
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000506 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100507 # This will fetch changes[start..start+limit] sorted by most recently
508 # updated. Since the rank of any change in this list can be changed any time
509 # (say user posting comment), subsequent calls may overalp like this:
510 # > initial order ABCDEFGH
511 # query[0..3] => ABC
512 # > E get's updated. New order: EABCDFGH
513 # query[3..6] => CDF # C is a dup
514 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200515 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100516 cur_start)
517 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000518 yield cl
519
520 more_changes = [cl for cl in page if '_more_changes' in cl]
521 if len(more_changes) > 1:
522 raise GerritError(
523 200,
524 'Received %d changes with a _more_changes attribute set but should '
525 'receive at most one.' % len(more_changes))
526 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100527 cur_start += len(page)
528
529 # If we paged through, query again the first page which in most circumstances
530 # will fetch all changes that were modified while this function was run.
531 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200532 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100533 for cl in at_most_once(page):
534 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000535
536
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200537def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100538 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000539 """Initiate a query composed of multiple sets of query parameters."""
540 if not change_list:
541 raise RuntimeError(
542 "MultiQueryChanges requires a list of change numbers/id's")
543 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200544 if params:
545 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000546 if limit:
547 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100548 if start:
549 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000550 if o_params:
551 q.extend(['o=%s' % p for p in o_params])
552 path = 'changes/?%s' % '&'.join(q)
553 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700554 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000555 except GerritError as e:
556 msg = '%s:\n%s' % (e.message, path)
557 raise GerritError(e.http_status, msg)
558 return result
559
560
561def GetGerritFetchUrl(host):
562 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
563 return '%s://%s/' % (GERRIT_PROTOCOL, host)
564
565
566def GetChangePageUrl(host, change_number):
567 """Given a gerrit host name and change number, return change page url."""
568 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
569
570
571def GetChangeUrl(host, change):
572 """Given a gerrit host name and change id, return an url for the change."""
573 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
574
575
576def GetChange(host, change):
577 """Query a gerrit server for information about a single change."""
578 path = 'changes/%s' % change
579 return ReadHttpJsonResponse(CreateHttpConn(host, path))
580
581
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700582def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000583 """Query a gerrit server for extended information about a single change."""
584 path = 'changes/%s/detail' % change
585 if o_params:
586 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700587 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000588
589
agable32978d92016-11-01 12:55:02 -0700590def GetChangeCommit(host, change, revision='current'):
591 """Query a gerrit server for a revision associated with a change."""
592 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
593 return ReadHttpJsonResponse(CreateHttpConn(host, path))
594
595
szager@chromium.orgb4696232013-10-16 19:45:35 +0000596def GetChangeCurrentRevision(host, change):
597 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200598 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000599
600
601def GetChangeRevisions(host, change):
602 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200603 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000604
605
606def GetChangeReview(host, change, revision=None):
607 """Get the current review information for a change."""
608 if not revision:
609 jmsg = GetChangeRevisions(host, change)
610 if not jmsg:
611 return None
612 elif len(jmsg) > 1:
613 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
614 revision = jmsg[0]['current_revision']
615 path = 'changes/%s/revisions/%s/review'
616 return ReadHttpJsonResponse(CreateHttpConn(host, path))
617
618
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700619def GetChangeComments(host, change):
620 """Get the line- and file-level comments on a change."""
621 path = 'changes/%s/comments' % change
622 return ReadHttpJsonResponse(CreateHttpConn(host, path))
623
624
szager@chromium.orgb4696232013-10-16 19:45:35 +0000625def AbandonChange(host, change, msg=''):
626 """Abandon a gerrit change."""
627 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000628 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000629 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700630 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000631
632
633def RestoreChange(host, change, msg=''):
634 """Restore a previously abandoned change."""
635 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000636 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000637 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700638 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000639
640
641def SubmitChange(host, change, wait_for_merge=True):
642 """Submits a gerrit change via Gerrit."""
643 path = 'changes/%s/submit' % change
644 body = {'wait_for_merge': wait_for_merge}
645 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700646 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000647
648
dsansomee2d6fd92016-09-08 00:10:47 -0700649def HasPendingChangeEdit(host, change):
650 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
651 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700652 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700653 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700654 # 204 No Content means no pending change.
655 if e.http_status == 204:
656 return False
657 raise
658 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700659
660
661def DeletePendingChangeEdit(host, change):
662 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700663 # On success, gerrit returns status 204; if the edit was already deleted it
664 # returns 404. Anything else is an error.
665 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700666
667
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100668def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000669 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700670 assert notify in ('ALL', 'NONE')
671 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700672 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700673 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000674 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700675 ReadHttpResponse(conn, accept_statuses=[200, 204])
676 except GerritError as e:
677 raise GerritError(
678 e.http_status,
679 'Received unexpected http status while editing message '
680 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000681
682
szager@chromium.orgb4696232013-10-16 19:45:35 +0000683def GetReviewers(host, change):
684 """Get information about all reviewers attached to a change."""
685 path = 'changes/%s/reviewers' % change
686 return ReadHttpJsonResponse(CreateHttpConn(host, path))
687
688
689def GetReview(host, change, revision):
690 """Get review information about a specific revision of a change."""
691 path = 'changes/%s/revisions/%s/review' % (change, revision)
692 return ReadHttpJsonResponse(CreateHttpConn(host, path))
693
694
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700695def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
696 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000697 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700698 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800699 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200700 if not change:
701 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700702 reviewers = frozenset(reviewers or [])
703 ccs = frozenset(ccs or [])
704 path = 'changes/%s/revisions/current/review' % change
705
706 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800707 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700708 'reviewers': [],
709 'notify': 'ALL' if notify else 'NONE',
710 }
711 for r in sorted(reviewers | ccs):
712 state = 'REVIEWER' if r in reviewers else 'CC'
713 body['reviewers'].append({
714 'reviewer': r,
715 'state': state,
716 'notify': 'NONE', # We handled `notify` argument above.
717 })
718
719 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
720 # Gerrit will return 400 if one or more of the requested reviewers are
721 # unprocessable. We read the response object to see which were rejected,
722 # warn about them, and retry with the remainder.
723 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
724
725 errored = set()
726 for result in resp.get('reviewers', {}).itervalues():
727 r = result.get('input')
728 state = 'REVIEWER' if r in reviewers else 'CC'
729 if result.get('error'):
730 errored.add(r)
731 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
732 if errored:
733 # Try again, adding only those that didn't fail, and only accepting 200.
734 AddReviewers(host, change, reviewers=(reviewers-errored),
735 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000736
737
738def RemoveReviewers(host, change, remove=None):
739 """Remove reveiewers from a change."""
740 if not remove:
741 return
742 if isinstance(remove, basestring):
743 remove = (remove,)
744 for r in remove:
745 path = 'changes/%s/reviewers/%s' % (change, r)
746 conn = CreateHttpConn(host, path, reqtype='DELETE')
747 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700748 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000749 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000750 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700751 e.http_status,
752 'Received unexpected http status while deleting reviewer "%s" '
753 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000754
755
Aaron Gable636b13f2017-07-14 10:42:48 -0700756def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000757 """Set labels and/or add a message to a code review."""
758 if not msg and not labels:
759 return
760 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800761 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000762 if msg:
763 body['message'] = msg
764 if labels:
765 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700766 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700767 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700768 if ready:
769 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000770 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
771 response = ReadHttpJsonResponse(conn)
772 if labels:
773 for key, val in labels.iteritems():
774 if ('labels' not in response or key not in response['labels'] or
775 int(response['labels'][key] != int(val))):
776 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
777 key, change))
778
779
780def ResetReviewLabels(host, change, label, value='0', message=None,
781 notify=None):
782 """Reset the value of a given label for all reviewers on a change."""
783 # This is tricky, because we want to work on the "current revision", but
784 # there's always the risk that "current revision" will change in between
785 # API calls. So, we check "current revision" at the beginning and end; if
786 # it has changed, raise an exception.
787 jmsg = GetChangeCurrentRevision(host, change)
788 if not jmsg:
789 raise GerritError(
790 200, 'Could not get review information for change "%s"' % change)
791 value = str(value)
792 revision = jmsg[0]['current_revision']
793 path = 'changes/%s/revisions/%s/review' % (change, revision)
794 message = message or (
795 '%s label set to %s programmatically.' % (label, value))
796 jmsg = GetReview(host, change, revision)
797 if not jmsg:
798 raise GerritError(200, 'Could not get review information for revison %s '
799 'of change %s' % (revision, change))
800 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
801 if str(review.get('value', value)) != value:
802 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800803 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000804 'message': message,
805 'labels': {label: value},
806 'on_behalf_of': review['_account_id'],
807 }
808 if notify:
809 body['notify'] = notify
810 conn = CreateHttpConn(
811 host, path, reqtype='POST', body=body)
812 response = ReadHttpJsonResponse(conn)
813 if str(response['labels'][label]) != value:
814 username = review.get('email', jmsg.get('name', ''))
815 raise GerritError(200, 'Unable to set %s label for user "%s"'
816 ' on change %s.' % (label, username, change))
817 jmsg = GetChangeCurrentRevision(host, change)
818 if not jmsg:
819 raise GerritError(
820 200, 'Could not get review information for change "%s"' % change)
821 elif jmsg[0]['current_revision'] != revision:
822 raise GerritError(200, 'While resetting labels on change "%s", '
823 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800824
825
dimu833c94c2017-01-18 17:36:15 -0800826def CreateGerritBranch(host, project, branch, commit):
827 """
828 Create a new branch from given project and commit
829 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
830
831 Returns:
832 A JSON with 'ref' key
833 """
834 path = 'projects/%s/branches/%s' % (project, branch)
835 body = {'revision': commit}
836 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700837 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800838 if response:
839 return response
840 raise GerritError(200, 'Unable to create gerrit branch')
841
842
843def GetGerritBranch(host, project, branch):
844 """
845 Get a branch from given project and commit
846 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
847
848 Returns:
849 A JSON object with 'revision' key
850 """
851 path = 'projects/%s/branches/%s' % (project, branch)
852 conn = CreateHttpConn(host, path, reqtype='GET')
853 response = ReadHttpJsonResponse(conn)
854 if response:
855 return response
856 raise GerritError(200, 'Unable to get gerrit branch')
857
858
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100859def GetAccountDetails(host, account_id='self'):
860 """Returns details of the account.
861
862 If account_id is not given, uses magic value 'self' which corresponds to
863 whichever account user is authenticating as.
864
865 Documentation:
866 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
867 """
868 if account_id != 'self':
869 account_id = int(account_id)
870 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
871 return ReadHttpJsonResponse(conn)
872
873
Nick Carter8692b182017-11-06 16:30:38 -0800874def PercentEncodeForGitRef(original):
875 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
876
877 The encoding used is based on but stricter than URL encoding (Section 2.1
878 of RFC 3986). The only non-escaped characters are alphanumerics, and
879 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
880 'PLUS SIGN' (U+002B).
881
882 For more information, see the Gerrit docs here:
883
884 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
885 """
886 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
887 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
888
889 # spaces are not allowed in git refs; gerrit will interpret either '_' or
890 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
891 return encoded.replace(' ', '_')
892
893
Dan Jacques8d11e482016-11-15 14:25:56 -0800894@contextlib.contextmanager
895def tempdir():
896 tdir = None
897 try:
898 tdir = tempfile.mkdtemp(suffix='gerrit_util')
899 yield tdir
900 finally:
901 if tdir:
902 gclient_utils.rmtree(tdir)