blob: f15f6830b9f7cbca7b0bc3691efb43f58cd45059 [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':
203 login, secret_token = value.split('=', 1)
204 gitcookies[domain] = (login, secret_token)
205 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100206 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000207
208 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:
219 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()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000364 conn.req_host = host
365 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100366 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000367 'method': reqtype,
368 'headers': headers,
369 'body': body,
370 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000371 return conn
372
373
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700374def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000375 """Reads an http response from a connection into a string buffer.
376
377 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100378 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700379 accept_statuses: Treat any of these statuses as success. Default: [200]
380 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000381 Returns: A string buffer containing the connection's reply.
382 """
Steve Kobes56117722018-09-13 18:18:35 +0000383 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000384 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100385 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000386
387 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100388 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000389 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
390 www_authenticate):
391 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
392 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700393 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000394 'has credentials for %s' % host)
395 raise GerritAuthenticationError(response.status, reason)
396
szager@chromium.orgb4696232013-10-16 19:45:35 +0000397 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000398 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700399 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000400 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700401 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100402 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100403 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700404 # If 404 was in accept_statuses, then it's expected that the file might
405 # not exist, so don't return the gitiles error page because that's not the
406 # "content" that was actually requested.
407 if response.status == 404:
408 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000409 break
410 # A status >=500 is assumed to be a possible transient error; retry.
411 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100412 LOGGER.warn('A transient error occurred while querying %s:\n'
413 '%s %s %s\n'
414 '%s %d %s',
Aaron Gable20d2cbb2017-04-25 15:04:05 -0700415 conn.req_host, conn.req_params['method'],
416 conn.req_params['uri'],
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100417 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000418 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800419 LOGGER.info('Will retry in %d seconds (%d more times)...',
420 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000421 time.sleep(sleep_time)
422 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700423 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200424 if response.status in (401, 403):
425 print('Your Gerrit credentials might be misconfigured. Try: \n'
426 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100427 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000428 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100429 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000430
431
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700432def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000433 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700434 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000435 # The first line of the response should always be: )]}'
436 s = fh.readline()
437 if s and s.rstrip() != ")]}'":
438 raise GerritError(200, 'Unexpected json output: %s' % s)
439 s = fh.read()
440 if not s:
441 return None
442 return json.loads(s)
443
444
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200445def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100446 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000447 """
448 Queries a gerrit-on-borg server for changes matching query terms.
449
450 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200451 params: A list of key:value pairs for search parameters, as documented
452 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
453 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000454 first_param: A change identifier
455 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100456 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000457 o_params: A list of additional output specifiers, as documented here:
458 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
459 Returns:
460 A list of json-decoded query results.
461 """
462 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200463 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000464 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200465 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100466 if start:
467 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000468 if limit:
469 path = '%s&n=%d' % (path, limit)
470 if o_params:
471 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700472 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000473
474
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200475def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100476 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000477 """
478 Queries a gerrit-on-borg server for all the changes matching the query terms.
479
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100480 WARNING: this is unreliable if a change matching the query is modified while
481 this function is being called.
482
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000483 A single query to gerrit-on-borg is limited on the number of results by the
484 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100485 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000486
487 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200488 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000489 limit: Maximum number of requested changes per query.
490 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100491 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000492
493 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100494 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000495 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100496 already_returned = set()
497 def at_most_once(cls):
498 for cl in cls:
499 if cl['_number'] not in already_returned:
500 already_returned.add(cl['_number'])
501 yield cl
502
503 start = start or 0
504 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000505 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100506
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000507 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100508 # This will fetch changes[start..start+limit] sorted by most recently
509 # updated. Since the rank of any change in this list can be changed any time
510 # (say user posting comment), subsequent calls may overalp like this:
511 # > initial order ABCDEFGH
512 # query[0..3] => ABC
513 # > E get's updated. New order: EABCDFGH
514 # query[3..6] => CDF # C is a dup
515 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200516 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100517 cur_start)
518 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000519 yield cl
520
521 more_changes = [cl for cl in page if '_more_changes' in cl]
522 if len(more_changes) > 1:
523 raise GerritError(
524 200,
525 'Received %d changes with a _more_changes attribute set but should '
526 'receive at most one.' % len(more_changes))
527 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100528 cur_start += len(page)
529
530 # If we paged through, query again the first page which in most circumstances
531 # will fetch all changes that were modified while this function was run.
532 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200533 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100534 for cl in at_most_once(page):
535 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000536
537
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200538def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100539 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000540 """Initiate a query composed of multiple sets of query parameters."""
541 if not change_list:
542 raise RuntimeError(
543 "MultiQueryChanges requires a list of change numbers/id's")
544 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200545 if params:
546 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000547 if limit:
548 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100549 if start:
550 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000551 if o_params:
552 q.extend(['o=%s' % p for p in o_params])
553 path = 'changes/?%s' % '&'.join(q)
554 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700555 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000556 except GerritError as e:
557 msg = '%s:\n%s' % (e.message, path)
558 raise GerritError(e.http_status, msg)
559 return result
560
561
562def GetGerritFetchUrl(host):
563 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
564 return '%s://%s/' % (GERRIT_PROTOCOL, host)
565
566
567def GetChangePageUrl(host, change_number):
568 """Given a gerrit host name and change number, return change page url."""
569 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
570
571
572def GetChangeUrl(host, change):
573 """Given a gerrit host name and change id, return an url for the change."""
574 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
575
576
577def GetChange(host, change):
578 """Query a gerrit server for information about a single change."""
579 path = 'changes/%s' % change
580 return ReadHttpJsonResponse(CreateHttpConn(host, path))
581
582
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700583def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000584 """Query a gerrit server for extended information about a single change."""
585 path = 'changes/%s/detail' % change
586 if o_params:
587 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700588 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000589
590
agable32978d92016-11-01 12:55:02 -0700591def GetChangeCommit(host, change, revision='current'):
592 """Query a gerrit server for a revision associated with a change."""
593 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
594 return ReadHttpJsonResponse(CreateHttpConn(host, path))
595
596
szager@chromium.orgb4696232013-10-16 19:45:35 +0000597def GetChangeCurrentRevision(host, change):
598 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200599 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000600
601
602def GetChangeRevisions(host, change):
603 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200604 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000605
606
607def GetChangeReview(host, change, revision=None):
608 """Get the current review information for a change."""
609 if not revision:
610 jmsg = GetChangeRevisions(host, change)
611 if not jmsg:
612 return None
613 elif len(jmsg) > 1:
614 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
615 revision = jmsg[0]['current_revision']
616 path = 'changes/%s/revisions/%s/review'
617 return ReadHttpJsonResponse(CreateHttpConn(host, path))
618
619
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700620def GetChangeComments(host, change):
621 """Get the line- and file-level comments on a change."""
622 path = 'changes/%s/comments' % change
623 return ReadHttpJsonResponse(CreateHttpConn(host, path))
624
625
szager@chromium.orgb4696232013-10-16 19:45:35 +0000626def AbandonChange(host, change, msg=''):
627 """Abandon a gerrit change."""
628 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000629 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000630 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700631 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000632
633
634def RestoreChange(host, change, msg=''):
635 """Restore a previously abandoned change."""
636 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000637 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000638 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700639 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000640
641
642def SubmitChange(host, change, wait_for_merge=True):
643 """Submits a gerrit change via Gerrit."""
644 path = 'changes/%s/submit' % change
645 body = {'wait_for_merge': wait_for_merge}
646 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700647 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000648
649
dsansomee2d6fd92016-09-08 00:10:47 -0700650def HasPendingChangeEdit(host, change):
651 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
652 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700653 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700654 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700655 # 204 No Content means no pending change.
656 if e.http_status == 204:
657 return False
658 raise
659 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700660
661
662def DeletePendingChangeEdit(host, change):
663 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700664 # On success, gerrit returns status 204; if the edit was already deleted it
665 # returns 404. Anything else is an error.
666 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700667
668
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100669def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000670 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700671 assert notify in ('ALL', 'NONE')
672 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700673 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700674 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000675 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700676 ReadHttpResponse(conn, accept_statuses=[200, 204])
677 except GerritError as e:
678 raise GerritError(
679 e.http_status,
680 'Received unexpected http status while editing message '
681 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000682
683
szager@chromium.orgb4696232013-10-16 19:45:35 +0000684def GetReviewers(host, change):
685 """Get information about all reviewers attached to a change."""
686 path = 'changes/%s/reviewers' % change
687 return ReadHttpJsonResponse(CreateHttpConn(host, path))
688
689
690def GetReview(host, change, revision):
691 """Get review information about a specific revision of a change."""
692 path = 'changes/%s/revisions/%s/review' % (change, revision)
693 return ReadHttpJsonResponse(CreateHttpConn(host, path))
694
695
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700696def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
697 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000698 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700699 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800700 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200701 if not change:
702 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700703 reviewers = frozenset(reviewers or [])
704 ccs = frozenset(ccs or [])
705 path = 'changes/%s/revisions/current/review' % change
706
707 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800708 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700709 'reviewers': [],
710 'notify': 'ALL' if notify else 'NONE',
711 }
712 for r in sorted(reviewers | ccs):
713 state = 'REVIEWER' if r in reviewers else 'CC'
714 body['reviewers'].append({
715 'reviewer': r,
716 'state': state,
717 'notify': 'NONE', # We handled `notify` argument above.
718 })
719
720 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
721 # Gerrit will return 400 if one or more of the requested reviewers are
722 # unprocessable. We read the response object to see which were rejected,
723 # warn about them, and retry with the remainder.
724 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
725
726 errored = set()
727 for result in resp.get('reviewers', {}).itervalues():
728 r = result.get('input')
729 state = 'REVIEWER' if r in reviewers else 'CC'
730 if result.get('error'):
731 errored.add(r)
732 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
733 if errored:
734 # Try again, adding only those that didn't fail, and only accepting 200.
735 AddReviewers(host, change, reviewers=(reviewers-errored),
736 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000737
738
739def RemoveReviewers(host, change, remove=None):
740 """Remove reveiewers from a change."""
741 if not remove:
742 return
743 if isinstance(remove, basestring):
744 remove = (remove,)
745 for r in remove:
746 path = 'changes/%s/reviewers/%s' % (change, r)
747 conn = CreateHttpConn(host, path, reqtype='DELETE')
748 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700749 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000750 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000751 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700752 e.http_status,
753 'Received unexpected http status while deleting reviewer "%s" '
754 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000755
756
Aaron Gable636b13f2017-07-14 10:42:48 -0700757def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000758 """Set labels and/or add a message to a code review."""
759 if not msg and not labels:
760 return
761 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800762 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000763 if msg:
764 body['message'] = msg
765 if labels:
766 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700767 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700768 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700769 if ready:
770 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000771 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
772 response = ReadHttpJsonResponse(conn)
773 if labels:
774 for key, val in labels.iteritems():
775 if ('labels' not in response or key not in response['labels'] or
776 int(response['labels'][key] != int(val))):
777 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
778 key, change))
779
780
781def ResetReviewLabels(host, change, label, value='0', message=None,
782 notify=None):
783 """Reset the value of a given label for all reviewers on a change."""
784 # This is tricky, because we want to work on the "current revision", but
785 # there's always the risk that "current revision" will change in between
786 # API calls. So, we check "current revision" at the beginning and end; if
787 # it has changed, raise an exception.
788 jmsg = GetChangeCurrentRevision(host, change)
789 if not jmsg:
790 raise GerritError(
791 200, 'Could not get review information for change "%s"' % change)
792 value = str(value)
793 revision = jmsg[0]['current_revision']
794 path = 'changes/%s/revisions/%s/review' % (change, revision)
795 message = message or (
796 '%s label set to %s programmatically.' % (label, value))
797 jmsg = GetReview(host, change, revision)
798 if not jmsg:
799 raise GerritError(200, 'Could not get review information for revison %s '
800 'of change %s' % (revision, change))
801 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
802 if str(review.get('value', value)) != value:
803 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800804 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000805 'message': message,
806 'labels': {label: value},
807 'on_behalf_of': review['_account_id'],
808 }
809 if notify:
810 body['notify'] = notify
811 conn = CreateHttpConn(
812 host, path, reqtype='POST', body=body)
813 response = ReadHttpJsonResponse(conn)
814 if str(response['labels'][label]) != value:
815 username = review.get('email', jmsg.get('name', ''))
816 raise GerritError(200, 'Unable to set %s label for user "%s"'
817 ' on change %s.' % (label, username, change))
818 jmsg = GetChangeCurrentRevision(host, change)
819 if not jmsg:
820 raise GerritError(
821 200, 'Could not get review information for change "%s"' % change)
822 elif jmsg[0]['current_revision'] != revision:
823 raise GerritError(200, 'While resetting labels on change "%s", '
824 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800825
826
dimu833c94c2017-01-18 17:36:15 -0800827def CreateGerritBranch(host, project, branch, commit):
828 """
829 Create a new branch from given project and commit
830 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
831
832 Returns:
833 A JSON with 'ref' key
834 """
835 path = 'projects/%s/branches/%s' % (project, branch)
836 body = {'revision': commit}
837 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700838 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800839 if response:
840 return response
841 raise GerritError(200, 'Unable to create gerrit branch')
842
843
844def GetGerritBranch(host, project, branch):
845 """
846 Get a branch from given project and commit
847 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
848
849 Returns:
850 A JSON object with 'revision' key
851 """
852 path = 'projects/%s/branches/%s' % (project, branch)
853 conn = CreateHttpConn(host, path, reqtype='GET')
854 response = ReadHttpJsonResponse(conn)
855 if response:
856 return response
857 raise GerritError(200, 'Unable to get gerrit branch')
858
859
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100860def GetAccountDetails(host, account_id='self'):
861 """Returns details of the account.
862
863 If account_id is not given, uses magic value 'self' which corresponds to
864 whichever account user is authenticating as.
865
866 Documentation:
867 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
868 """
869 if account_id != 'self':
870 account_id = int(account_id)
871 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
872 return ReadHttpJsonResponse(conn)
873
874
Nick Carter8692b182017-11-06 16:30:38 -0800875def PercentEncodeForGitRef(original):
876 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
877
878 The encoding used is based on but stricter than URL encoding (Section 2.1
879 of RFC 3986). The only non-escaped characters are alphanumerics, and
880 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
881 'PLUS SIGN' (U+002B).
882
883 For more information, see the Gerrit docs here:
884
885 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
886 """
887 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
888 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
889
890 # spaces are not allowed in git refs; gerrit will interpret either '_' or
891 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
892 return encoded.replace(' ', '_')
893
894
Dan Jacques8d11e482016-11-15 14:25:56 -0800895@contextlib.contextmanager
896def tempdir():
897 tdir = None
898 try:
899 tdir = tempfile.mkdtemp(suffix='gerrit_util')
900 yield tdir
901 finally:
902 if tdir:
903 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000904
905
906def ChangeIdentifier(project, change_number):
907 """Returns change identifier "project~number" suitable for |chagne| arg of
908 this module API.
909
910 Such format is allows for more efficient Gerrit routing of HTTP requests,
911 comparing to specifying just change_number.
912 """
913 assert int(change_number)
914 return '%s~%s' % (urllib.quote(project, safe=''), change_number)