blob: 81b28e72893bbb45655676c4d9c88eb31d005896 [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()
Dan Jacques8d11e482016-11-15 14:25:56 -0800137 content = ''
138 if os.path.exists(path):
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000139 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000140 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
141 print >> sys.stderr, (
142 'WARNING: netrc file %s cannot be used because its file '
143 'permissions are insecure. netrc file permissions should be '
144 '600.' % path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800145 with open(path) as fd:
146 content = fd.read()
147
148 # Load the '.netrc' file. We strip comments from it because processing them
149 # can trigger a bug in Windows. See crbug.com/664664.
150 content = '\n'.join(l for l in content.splitlines()
151 if l.strip() and not l.strip().startswith('#'))
152 with tempdir() as tdir:
153 netrc_path = os.path.join(tdir, 'netrc')
154 with open(netrc_path, 'w') as fd:
155 fd.write(content)
156 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
157 return cls._get_netrc_from_path(netrc_path)
158
159 @classmethod
160 def _get_netrc_from_path(cls, path):
161 try:
162 return netrc.netrc(path)
163 except IOError:
164 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
165 return netrc.netrc(os.devnull)
166 except netrc.NetrcParseError as e:
167 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
168 'parsing error: %s' % (path, e))
169 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000170
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000171 @classmethod
172 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500173 if os.getenv('GIT_COOKIES_PATH'):
174 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800175 try:
176 return subprocess2.check_output(
177 ['git', 'config', '--path', 'http.cookiefile']).strip()
178 except subprocess2.CalledProcessError:
179 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000180
181 @classmethod
182 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000183 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000184 path = cls.get_gitcookies_path()
185 if not os.path.exists(path):
186 return gitcookies
187
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000188 try:
189 f = open(path, 'rb')
190 except IOError:
191 return gitcookies
192
193 with f:
194 for line in f:
195 try:
196 fields = line.strip().split('\t')
197 if line.strip().startswith('#') or len(fields) != 7:
198 continue
199 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
200 if xpath == '/' and key == 'o':
201 login, secret_token = value.split('=', 1)
202 gitcookies[domain] = (login, secret_token)
203 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100204 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000205
206 return gitcookies
207
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100208 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000209 for domain, creds in self.gitcookies.iteritems():
210 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100211 return (creds[0], None, creds[1])
212 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000213
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100214 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700215 a = self._get_auth_for_host(host)
216 if a:
217 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000218 return None
219
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100220 def get_auth_email(self, host):
221 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700222 a = self._get_auth_for_host(host)
223 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100224 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700225 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100226 # login typically looks like 'git-xxx.example.com'
227 if not login.startswith('git-') or '.' not in login:
228 return None
229 username, domain = login[len('git-'):].split('.', 1)
230 return '%s@%s' % (username, domain)
231
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100232
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000233# Backwards compatibility just in case somebody imports this outside of
234# depot_tools.
235NetrcAuthenticator = CookiesAuthenticator
236
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000237
238class GceAuthenticator(Authenticator):
239 """Authenticator implementation that uses GCE metadata service for token.
240 """
241
242 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700243 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
244 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000245 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
246
247 _cache_is_gce = None
248 _token_cache = None
249 _token_expiration = None
250
251 @classmethod
252 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500253 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
254 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000255 if cls._cache_is_gce is None:
256 cls._cache_is_gce = cls._test_is_gce()
257 return cls._cache_is_gce
258
259 @classmethod
260 def _test_is_gce(cls):
261 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
262 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100263 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100264 except (socket.error, httplib2.ServerNotFoundError,
265 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000266 # Could not resolve URL.
267 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100268 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000269
270 @staticmethod
271 def _get(url, **kwargs):
272 next_delay_sec = 1
273 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000274 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700275 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100276 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000277 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
278 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100279 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000280
Aaron Gable92e9f382017-12-07 11:47:41 -0800281 # Retry server error status codes.
282 LOGGER.warn('Encountered server error')
283 if TRY_LIMIT - i > 1:
284 LOGGER.info('Will retry in %d seconds (%d more times)...',
285 next_delay_sec, TRY_LIMIT - i - 1)
286 time.sleep(next_delay_sec)
287 next_delay_sec *= 2
288
289
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000290 @classmethod
291 def _get_token_dict(cls):
292 if cls._token_cache:
293 # If it expires within 25 seconds, refresh.
294 if cls._token_expiration < time.time() - 25:
295 return cls._token_cache
296
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100297 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000298 if resp.status != httplib.OK:
299 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100300 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000301 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
302 return cls._token_cache
303
304 def get_auth_header(self, _host):
305 token_dict = self._get_token_dict()
306 if not token_dict:
307 return None
308 return '%(token_type)s %(access_token)s' % token_dict
309
310
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700311class LuciContextAuthenticator(Authenticator):
312 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
313 """
314
315 @staticmethod
316 def is_luci():
317 return auth.has_luci_context_local_auth()
318
319 def __init__(self):
320 self._access_token = None
321 self._ensure_fresh()
322
323 def _ensure_fresh(self):
324 if not self._access_token or self._access_token.needs_refresh():
325 self._access_token = auth.get_luci_context_access_token(
326 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
327
328 def get_auth_header(self, _host):
329 self._ensure_fresh()
330 return 'Bearer %s' % self._access_token.token
331
332
szager@chromium.orgb4696232013-10-16 19:45:35 +0000333def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
334 """Opens an https connection to a gerrit service, and sends a request."""
335 headers = headers or {}
336 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000337
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700338 a = Authenticator.get().get_auth_header(bare_host)
339 if a:
340 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000341 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000342 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000343
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800344 url = path
345 if not url.startswith('/'):
346 url = '/' + url
347 if 'Authorization' in headers and not url.startswith('/a/'):
348 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000349
szager@chromium.orgb4696232013-10-16 19:45:35 +0000350 if body:
351 body = json.JSONEncoder().encode(body)
352 headers.setdefault('Content-Type', 'application/json')
353 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000354 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000355 for key, val in headers.iteritems():
356 if key == 'Authorization':
357 val = 'HIDDEN'
358 LOGGER.debug('%s: %s' % (key, val))
359 if body:
360 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700361 conn = GetConnectionObject()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000362 conn.req_host = host
363 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100364 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000365 'method': reqtype,
366 'headers': headers,
367 'body': body,
368 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000369 return conn
370
371
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700372def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000373 """Reads an http response from a connection into a string buffer.
374
375 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100376 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700377 accept_statuses: Treat any of these statuses as success. Default: [200]
378 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000379 Returns: A string buffer containing the connection's reply.
380 """
Aaron Gable92e9f382017-12-07 11:47:41 -0800381 sleep_time = 1
szager@chromium.orgb4696232013-10-16 19:45:35 +0000382 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100383 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000384
385 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100386 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000387 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
388 www_authenticate):
389 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
390 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700391 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000392 'has credentials for %s' % host)
393 raise GerritAuthenticationError(response.status, reason)
394
szager@chromium.orgb4696232013-10-16 19:45:35 +0000395 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000396 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700397 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000398 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700399 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100400 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100401 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700402 # If 404 was in accept_statuses, then it's expected that the file might
403 # not exist, so don't return the gitiles error page because that's not the
404 # "content" that was actually requested.
405 if response.status == 404:
406 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000407 break
408 # A status >=500 is assumed to be a possible transient error; retry.
409 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100410 LOGGER.warn('A transient error occurred while querying %s:\n'
411 '%s %s %s\n'
412 '%s %d %s',
Aaron Gable20d2cbb2017-04-25 15:04:05 -0700413 conn.req_host, conn.req_params['method'],
414 conn.req_params['uri'],
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100415 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000416 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800417 LOGGER.info('Will retry in %d seconds (%d more times)...',
418 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000419 time.sleep(sleep_time)
420 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700421 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200422 if response.status in (401, 403):
423 print('Your Gerrit credentials might be misconfigured. Try: \n'
424 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100425 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000426 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100427 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000428
429
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700430def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000431 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700432 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000433 # The first line of the response should always be: )]}'
434 s = fh.readline()
435 if s and s.rstrip() != ")]}'":
436 raise GerritError(200, 'Unexpected json output: %s' % s)
437 s = fh.read()
438 if not s:
439 return None
440 return json.loads(s)
441
442
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200443def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100444 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000445 """
446 Queries a gerrit-on-borg server for changes matching query terms.
447
448 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200449 params: A list of key:value pairs for search parameters, as documented
450 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
451 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000452 first_param: A change identifier
453 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100454 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000455 o_params: A list of additional output specifiers, as documented here:
456 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
457 Returns:
458 A list of json-decoded query results.
459 """
460 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200461 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000462 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200463 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100464 if start:
465 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000466 if limit:
467 path = '%s&n=%d' % (path, limit)
468 if o_params:
469 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700470 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000471
472
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200473def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100474 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000475 """
476 Queries a gerrit-on-borg server for all the changes matching the query terms.
477
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100478 WARNING: this is unreliable if a change matching the query is modified while
479 this function is being called.
480
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000481 A single query to gerrit-on-borg is limited on the number of results by the
482 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100483 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000484
485 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200486 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000487 limit: Maximum number of requested changes per query.
488 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100489 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000490
491 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100492 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000493 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100494 already_returned = set()
495 def at_most_once(cls):
496 for cl in cls:
497 if cl['_number'] not in already_returned:
498 already_returned.add(cl['_number'])
499 yield cl
500
501 start = start or 0
502 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000503 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100504
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000505 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100506 # This will fetch changes[start..start+limit] sorted by most recently
507 # updated. Since the rank of any change in this list can be changed any time
508 # (say user posting comment), subsequent calls may overalp like this:
509 # > initial order ABCDEFGH
510 # query[0..3] => ABC
511 # > E get's updated. New order: EABCDFGH
512 # query[3..6] => CDF # C is a dup
513 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200514 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100515 cur_start)
516 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000517 yield cl
518
519 more_changes = [cl for cl in page if '_more_changes' in cl]
520 if len(more_changes) > 1:
521 raise GerritError(
522 200,
523 'Received %d changes with a _more_changes attribute set but should '
524 'receive at most one.' % len(more_changes))
525 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100526 cur_start += len(page)
527
528 # If we paged through, query again the first page which in most circumstances
529 # will fetch all changes that were modified while this function was run.
530 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200531 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100532 for cl in at_most_once(page):
533 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000534
535
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200536def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100537 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000538 """Initiate a query composed of multiple sets of query parameters."""
539 if not change_list:
540 raise RuntimeError(
541 "MultiQueryChanges requires a list of change numbers/id's")
542 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200543 if params:
544 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000545 if limit:
546 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100547 if start:
548 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000549 if o_params:
550 q.extend(['o=%s' % p for p in o_params])
551 path = 'changes/?%s' % '&'.join(q)
552 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700553 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000554 except GerritError as e:
555 msg = '%s:\n%s' % (e.message, path)
556 raise GerritError(e.http_status, msg)
557 return result
558
559
560def GetGerritFetchUrl(host):
561 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
562 return '%s://%s/' % (GERRIT_PROTOCOL, host)
563
564
565def GetChangePageUrl(host, change_number):
566 """Given a gerrit host name and change number, return change page url."""
567 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
568
569
570def GetChangeUrl(host, change):
571 """Given a gerrit host name and change id, return an url for the change."""
572 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
573
574
575def GetChange(host, change):
576 """Query a gerrit server for information about a single change."""
577 path = 'changes/%s' % change
578 return ReadHttpJsonResponse(CreateHttpConn(host, path))
579
580
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700581def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000582 """Query a gerrit server for extended information about a single change."""
583 path = 'changes/%s/detail' % change
584 if o_params:
585 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700586 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000587
588
agable32978d92016-11-01 12:55:02 -0700589def GetChangeCommit(host, change, revision='current'):
590 """Query a gerrit server for a revision associated with a change."""
591 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
592 return ReadHttpJsonResponse(CreateHttpConn(host, path))
593
594
szager@chromium.orgb4696232013-10-16 19:45:35 +0000595def GetChangeCurrentRevision(host, change):
596 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200597 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000598
599
600def GetChangeRevisions(host, change):
601 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200602 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000603
604
605def GetChangeReview(host, change, revision=None):
606 """Get the current review information for a change."""
607 if not revision:
608 jmsg = GetChangeRevisions(host, change)
609 if not jmsg:
610 return None
611 elif len(jmsg) > 1:
612 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
613 revision = jmsg[0]['current_revision']
614 path = 'changes/%s/revisions/%s/review'
615 return ReadHttpJsonResponse(CreateHttpConn(host, path))
616
617
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700618def GetChangeComments(host, change):
619 """Get the line- and file-level comments on a change."""
620 path = 'changes/%s/comments' % change
621 return ReadHttpJsonResponse(CreateHttpConn(host, path))
622
623
szager@chromium.orgb4696232013-10-16 19:45:35 +0000624def AbandonChange(host, change, msg=''):
625 """Abandon a gerrit change."""
626 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000627 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000628 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700629 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000630
631
632def RestoreChange(host, change, msg=''):
633 """Restore a previously abandoned change."""
634 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000635 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000636 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700637 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000638
639
640def SubmitChange(host, change, wait_for_merge=True):
641 """Submits a gerrit change via Gerrit."""
642 path = 'changes/%s/submit' % change
643 body = {'wait_for_merge': wait_for_merge}
644 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700645 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000646
647
dsansomee2d6fd92016-09-08 00:10:47 -0700648def HasPendingChangeEdit(host, change):
649 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
650 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700651 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700652 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700653 # 204 No Content means no pending change.
654 if e.http_status == 204:
655 return False
656 raise
657 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700658
659
660def DeletePendingChangeEdit(host, change):
661 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700662 # On success, gerrit returns status 204; if the edit was already deleted it
663 # returns 404. Anything else is an error.
664 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700665
666
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100667def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000668 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700669 assert notify in ('ALL', 'NONE')
670 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700671 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700672 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000673 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700674 ReadHttpResponse(conn, accept_statuses=[200, 204])
675 except GerritError as e:
676 raise GerritError(
677 e.http_status,
678 'Received unexpected http status while editing message '
679 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000680
681
szager@chromium.orgb4696232013-10-16 19:45:35 +0000682def GetReviewers(host, change):
683 """Get information about all reviewers attached to a change."""
684 path = 'changes/%s/reviewers' % change
685 return ReadHttpJsonResponse(CreateHttpConn(host, path))
686
687
688def GetReview(host, change, revision):
689 """Get review information about a specific revision of a change."""
690 path = 'changes/%s/revisions/%s/review' % (change, revision)
691 return ReadHttpJsonResponse(CreateHttpConn(host, path))
692
693
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700694def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
695 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000696 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700697 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800698 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200699 if not change:
700 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700701 reviewers = frozenset(reviewers or [])
702 ccs = frozenset(ccs or [])
703 path = 'changes/%s/revisions/current/review' % change
704
705 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800706 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700707 'reviewers': [],
708 'notify': 'ALL' if notify else 'NONE',
709 }
710 for r in sorted(reviewers | ccs):
711 state = 'REVIEWER' if r in reviewers else 'CC'
712 body['reviewers'].append({
713 'reviewer': r,
714 'state': state,
715 'notify': 'NONE', # We handled `notify` argument above.
716 })
717
718 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
719 # Gerrit will return 400 if one or more of the requested reviewers are
720 # unprocessable. We read the response object to see which were rejected,
721 # warn about them, and retry with the remainder.
722 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
723
724 errored = set()
725 for result in resp.get('reviewers', {}).itervalues():
726 r = result.get('input')
727 state = 'REVIEWER' if r in reviewers else 'CC'
728 if result.get('error'):
729 errored.add(r)
730 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
731 if errored:
732 # Try again, adding only those that didn't fail, and only accepting 200.
733 AddReviewers(host, change, reviewers=(reviewers-errored),
734 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000735
736
737def RemoveReviewers(host, change, remove=None):
738 """Remove reveiewers from a change."""
739 if not remove:
740 return
741 if isinstance(remove, basestring):
742 remove = (remove,)
743 for r in remove:
744 path = 'changes/%s/reviewers/%s' % (change, r)
745 conn = CreateHttpConn(host, path, reqtype='DELETE')
746 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700747 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000748 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000749 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700750 e.http_status,
751 'Received unexpected http status while deleting reviewer "%s" '
752 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000753
754
Aaron Gable636b13f2017-07-14 10:42:48 -0700755def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000756 """Set labels and/or add a message to a code review."""
757 if not msg and not labels:
758 return
759 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800760 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000761 if msg:
762 body['message'] = msg
763 if labels:
764 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700765 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700766 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700767 if ready:
768 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000769 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
770 response = ReadHttpJsonResponse(conn)
771 if labels:
772 for key, val in labels.iteritems():
773 if ('labels' not in response or key not in response['labels'] or
774 int(response['labels'][key] != int(val))):
775 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
776 key, change))
777
778
779def ResetReviewLabels(host, change, label, value='0', message=None,
780 notify=None):
781 """Reset the value of a given label for all reviewers on a change."""
782 # This is tricky, because we want to work on the "current revision", but
783 # there's always the risk that "current revision" will change in between
784 # API calls. So, we check "current revision" at the beginning and end; if
785 # it has changed, raise an exception.
786 jmsg = GetChangeCurrentRevision(host, change)
787 if not jmsg:
788 raise GerritError(
789 200, 'Could not get review information for change "%s"' % change)
790 value = str(value)
791 revision = jmsg[0]['current_revision']
792 path = 'changes/%s/revisions/%s/review' % (change, revision)
793 message = message or (
794 '%s label set to %s programmatically.' % (label, value))
795 jmsg = GetReview(host, change, revision)
796 if not jmsg:
797 raise GerritError(200, 'Could not get review information for revison %s '
798 'of change %s' % (revision, change))
799 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
800 if str(review.get('value', value)) != value:
801 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800802 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000803 'message': message,
804 'labels': {label: value},
805 'on_behalf_of': review['_account_id'],
806 }
807 if notify:
808 body['notify'] = notify
809 conn = CreateHttpConn(
810 host, path, reqtype='POST', body=body)
811 response = ReadHttpJsonResponse(conn)
812 if str(response['labels'][label]) != value:
813 username = review.get('email', jmsg.get('name', ''))
814 raise GerritError(200, 'Unable to set %s label for user "%s"'
815 ' on change %s.' % (label, username, change))
816 jmsg = GetChangeCurrentRevision(host, change)
817 if not jmsg:
818 raise GerritError(
819 200, 'Could not get review information for change "%s"' % change)
820 elif jmsg[0]['current_revision'] != revision:
821 raise GerritError(200, 'While resetting labels on change "%s", '
822 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800823
824
dimu833c94c2017-01-18 17:36:15 -0800825def CreateGerritBranch(host, project, branch, commit):
826 """
827 Create a new branch from given project and commit
828 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
829
830 Returns:
831 A JSON with 'ref' key
832 """
833 path = 'projects/%s/branches/%s' % (project, branch)
834 body = {'revision': commit}
835 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700836 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800837 if response:
838 return response
839 raise GerritError(200, 'Unable to create gerrit branch')
840
841
842def GetGerritBranch(host, project, branch):
843 """
844 Get a branch from given project and commit
845 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
846
847 Returns:
848 A JSON object with 'revision' key
849 """
850 path = 'projects/%s/branches/%s' % (project, branch)
851 conn = CreateHttpConn(host, path, reqtype='GET')
852 response = ReadHttpJsonResponse(conn)
853 if response:
854 return response
855 raise GerritError(200, 'Unable to get gerrit branch')
856
857
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100858def GetAccountDetails(host, account_id='self'):
859 """Returns details of the account.
860
861 If account_id is not given, uses magic value 'self' which corresponds to
862 whichever account user is authenticating as.
863
864 Documentation:
865 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
866 """
867 if account_id != 'self':
868 account_id = int(account_id)
869 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
870 return ReadHttpJsonResponse(conn)
871
872
Nick Carter8692b182017-11-06 16:30:38 -0800873def PercentEncodeForGitRef(original):
874 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
875
876 The encoding used is based on but stricter than URL encoding (Section 2.1
877 of RFC 3986). The only non-escaped characters are alphanumerics, and
878 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
879 'PLUS SIGN' (U+002B).
880
881 For more information, see the Gerrit docs here:
882
883 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
884 """
885 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
886 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
887
888 # spaces are not allowed in git refs; gerrit will interpret either '_' or
889 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
890 return encoded.replace(' ', '_')
891
892
Dan Jacques8d11e482016-11-15 14:25:56 -0800893@contextlib.contextmanager
894def tempdir():
895 tdir = None
896 try:
897 tdir = tempfile.mkdtemp(suffix='gerrit_util')
898 yield tdir
899 finally:
900 if tdir:
901 gclient_utils.rmtree(tdir)