blob: f7bd210d302810e26d98b23cf836d9d24f267609 [file] [log] [blame]
szager@chromium.orgb4696232013-10-16 19:45:35 +00001# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""
6Utilities for requesting information for a gerrit server via https.
7
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
11import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080012import contextlib
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000013import cookielib
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010014import httplib # Still used for its constants.
szager@chromium.orgb4696232013-10-16 19:45:35 +000015import json
16import logging
17import netrc
18import os
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000019import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000020import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000021import stat
22import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080023import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000024import time
25import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000026import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000027from cStringIO import StringIO
28
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070029import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080030import gclient_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080031import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010032from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000033
szager@chromium.orgb4696232013-10-16 19:45:35 +000034LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000035# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
36# total tries, the sleep time between the first and last tries will be 94.5 sec.
37# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
38TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000039
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000040
szager@chromium.orgb4696232013-10-16 19:45:35 +000041# Controls the transport protocol used to communicate with gerrit.
42# This is parameterized primarily to enable GerritTestCase.
43GERRIT_PROTOCOL = 'https'
44
45
46class GerritError(Exception):
47 """Exception class for errors commuicating with the gerrit-on-borg service."""
48 def __init__(self, http_status, *args, **kwargs):
49 super(GerritError, self).__init__(*args, **kwargs)
50 self.http_status = http_status
51 self.message = '(%d) %s' % (self.http_status, self.message)
52
53
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000054class GerritAuthenticationError(GerritError):
55 """Exception class for authentication errors during Gerrit communication."""
56
57
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020058def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000059 """Encodes query parameters in the key:val[+key:val...] format specified here:
60
61 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
62 """
63 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020064 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000065 return '+'.join(q)
66
67
Aaron Gabled2db5a22017-03-24 14:14:15 -070068def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000069 if protocol is None:
70 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010071 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070072 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000073 else:
74 raise RuntimeError(
75 "Don't know how to work with protocol '%s'" % protocol)
76
77
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000078class Authenticator(object):
79 """Base authenticator class for authenticator implementations to subclass."""
80
81 def get_auth_header(self, host):
82 raise NotImplementedError()
83
84 @staticmethod
85 def get():
86 """Returns: (Authenticator) The identified Authenticator to use.
87
88 Probes the local system and its environment and identifies the
89 Authenticator instance to use.
90 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070091 # LUCI Context takes priority since it's normally present only on bots,
92 # which then must use it.
93 if LuciContextAuthenticator.is_luci():
94 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000095 if GceAuthenticator.is_gce():
96 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000097 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000098
99
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000100class CookiesAuthenticator(Authenticator):
101 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
102
103 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000104 """
105
106 def __init__(self):
107 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000108 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000109
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000110 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200111 def get_new_password_url(cls, host):
112 assert not host.startswith('http')
113 # Assume *.googlesource.com pattern.
114 parts = host.split('.')
115 if not parts[0].endswith('-review'):
116 parts[0] += '-review'
117 return 'https://%s/new-password' % ('.'.join(parts))
118
119 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000120 def get_new_password_message(cls, host):
121 assert not host.startswith('http')
122 # Assume *.googlesource.com pattern.
123 parts = host.split('.')
124 if not parts[0].endswith('-review'):
125 parts[0] += '-review'
126 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100127 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000128
129 @classmethod
130 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000131 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000132 return os.path.expanduser(os.path.join('~', path))
133
134 @classmethod
135 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800136 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000137 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000138 if not os.path.exists(path):
139 return netrc.netrc(os.devnull)
140
141 st = os.stat(path)
142 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
143 print >> sys.stderr, (
144 'WARNING: netrc file %s cannot be used because its file '
145 'permissions are insecure. netrc file permissions should be '
146 '600.' % path)
147 with open(path) as fd:
148 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800149
150 # Load the '.netrc' file. We strip comments from it because processing them
151 # can trigger a bug in Windows. See crbug.com/664664.
152 content = '\n'.join(l for l in content.splitlines()
153 if l.strip() and not l.strip().startswith('#'))
154 with tempdir() as tdir:
155 netrc_path = os.path.join(tdir, 'netrc')
156 with open(netrc_path, 'w') as fd:
157 fd.write(content)
158 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
159 return cls._get_netrc_from_path(netrc_path)
160
161 @classmethod
162 def _get_netrc_from_path(cls, path):
163 try:
164 return netrc.netrc(path)
165 except IOError:
166 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
167 return netrc.netrc(os.devnull)
168 except netrc.NetrcParseError as e:
169 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
170 'parsing error: %s' % (path, e))
171 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000172
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000173 @classmethod
174 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500175 if os.getenv('GIT_COOKIES_PATH'):
176 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800177 try:
178 return subprocess2.check_output(
179 ['git', 'config', '--path', 'http.cookiefile']).strip()
180 except subprocess2.CalledProcessError:
181 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000182
183 @classmethod
184 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000185 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000186 path = cls.get_gitcookies_path()
187 if not os.path.exists(path):
188 return gitcookies
189
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000190 try:
191 f = open(path, 'rb')
192 except IOError:
193 return gitcookies
194
195 with f:
196 for line in f:
197 try:
198 fields = line.strip().split('\t')
199 if line.strip().startswith('#') or len(fields) != 7:
200 continue
201 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
202 if xpath == '/' and key == 'o':
Eric Boren05263352018-09-18 16:54:45 +0000203 login, secret_token = value.split('=', 1)
204 gitcookies[domain] = (login, secret_token)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000205 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100206 LOGGER.warning(exc)
Eric Boren05263352018-09-18 16:54:45 +0000207
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000208 return gitcookies
209
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100210 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000211 for domain, creds in self.gitcookies.iteritems():
212 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100213 return (creds[0], None, creds[1])
214 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000215
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100216 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700217 a = self._get_auth_for_host(host)
218 if a:
Eric Boren05263352018-09-18 16:54:45 +0000219 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000220 return None
221
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100222 def get_auth_email(self, host):
223 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700224 a = self._get_auth_for_host(host)
225 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100226 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700227 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100228 # login typically looks like 'git-xxx.example.com'
229 if not login.startswith('git-') or '.' not in login:
230 return None
231 username, domain = login[len('git-'):].split('.', 1)
232 return '%s@%s' % (username, domain)
233
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100234
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000235# Backwards compatibility just in case somebody imports this outside of
236# depot_tools.
237NetrcAuthenticator = CookiesAuthenticator
238
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000239
240class GceAuthenticator(Authenticator):
241 """Authenticator implementation that uses GCE metadata service for token.
242 """
243
244 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700245 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
246 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000247 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
248
249 _cache_is_gce = None
250 _token_cache = None
251 _token_expiration = None
252
253 @classmethod
254 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500255 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
256 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000257 if cls._cache_is_gce is None:
258 cls._cache_is_gce = cls._test_is_gce()
259 return cls._cache_is_gce
260
261 @classmethod
262 def _test_is_gce(cls):
263 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
264 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100265 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100266 except (socket.error, httplib2.ServerNotFoundError,
267 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000268 # Could not resolve URL.
269 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100270 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000271
272 @staticmethod
273 def _get(url, **kwargs):
274 next_delay_sec = 1
275 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000276 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700277 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100278 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000279 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
280 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100281 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000282
Aaron Gable92e9f382017-12-07 11:47:41 -0800283 # Retry server error status codes.
284 LOGGER.warn('Encountered server error')
285 if TRY_LIMIT - i > 1:
286 LOGGER.info('Will retry in %d seconds (%d more times)...',
287 next_delay_sec, TRY_LIMIT - i - 1)
288 time.sleep(next_delay_sec)
289 next_delay_sec *= 2
290
291
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000292 @classmethod
293 def _get_token_dict(cls):
294 if cls._token_cache:
295 # If it expires within 25 seconds, refresh.
296 if cls._token_expiration < time.time() - 25:
297 return cls._token_cache
298
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100299 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000300 if resp.status != httplib.OK:
301 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100302 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
304 return cls._token_cache
305
306 def get_auth_header(self, _host):
307 token_dict = self._get_token_dict()
308 if not token_dict:
309 return None
310 return '%(token_type)s %(access_token)s' % token_dict
311
312
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700313class LuciContextAuthenticator(Authenticator):
314 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
315 """
316
317 @staticmethod
318 def is_luci():
319 return auth.has_luci_context_local_auth()
320
321 def __init__(self):
322 self._access_token = None
323 self._ensure_fresh()
324
325 def _ensure_fresh(self):
326 if not self._access_token or self._access_token.needs_refresh():
327 self._access_token = auth.get_luci_context_access_token(
328 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
329
330 def get_auth_header(self, _host):
331 self._ensure_fresh()
332 return 'Bearer %s' % self._access_token.token
333
334
szager@chromium.orgb4696232013-10-16 19:45:35 +0000335def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
336 """Opens an https connection to a gerrit service, and sends a request."""
337 headers = headers or {}
338 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000339
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700340 a = Authenticator.get().get_auth_header(bare_host)
341 if a:
342 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000343 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000344 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000345
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800346 url = path
347 if not url.startswith('/'):
348 url = '/' + url
349 if 'Authorization' in headers and not url.startswith('/a/'):
350 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000351
szager@chromium.orgb4696232013-10-16 19:45:35 +0000352 if body:
353 body = json.JSONEncoder().encode(body)
354 headers.setdefault('Content-Type', 'application/json')
355 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000356 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000357 for key, val in headers.iteritems():
358 if key == 'Authorization':
359 val = 'HIDDEN'
360 LOGGER.debug('%s: %s' % (key, val))
361 if body:
362 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700363 conn = GetConnectionObject()
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 Shyshkalovea4301e2018-09-17 18:48:23 +0000412
413 # TODO(crbug/881860): remove this special 404 handling.
414 if response.status == 404:
415 LOGGER.warn(
416 '404 NotFound error occurred while querying %s %s: %s\n'
417 'NOTE: if see this while running `git cl upload`,\n'
418 'consider reporting this to https://crbug.com/881860.\n'
419 'Please, include response headers below:\n'
420 ' %s\n',
421 conn.req_params['method'], conn.req_params['uri'], response.reason,
422 '\n '.join(
423 json.dumps(response, sort_keys=True, indent=2).splitlines()))
424 else:
425 LOGGER.warn('A transient error occurred while querying %s:\n'
426 '%s %s\n'
427 '%s %d %s\n',
428 conn.req_host,
429 conn.req_params['method'], conn.req_params['uri'],
430 http_version, response.status, response.reason)
431
szager@chromium.orgb4696232013-10-16 19:45:35 +0000432 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800433 LOGGER.info('Will retry in %d seconds (%d more times)...',
434 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000435 time.sleep(sleep_time)
436 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700437 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200438 if response.status in (401, 403):
439 print('Your Gerrit credentials might be misconfigured. Try: \n'
440 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100441 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000442 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100443 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000444
445
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700446def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000447 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700448 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000449 # The first line of the response should always be: )]}'
450 s = fh.readline()
451 if s and s.rstrip() != ")]}'":
452 raise GerritError(200, 'Unexpected json output: %s' % s)
453 s = fh.read()
454 if not s:
455 return None
456 return json.loads(s)
457
458
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200459def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100460 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000461 """
462 Queries a gerrit-on-borg server for changes matching query terms.
463
464 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200465 params: A list of key:value pairs for search parameters, as documented
466 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
467 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000468 first_param: A change identifier
469 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100470 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000471 o_params: A list of additional output specifiers, as documented here:
472 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
473 Returns:
474 A list of json-decoded query results.
475 """
476 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200477 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000478 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200479 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100480 if start:
481 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000482 if limit:
483 path = '%s&n=%d' % (path, limit)
484 if o_params:
485 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700486 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000487
488
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200489def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100490 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000491 """
492 Queries a gerrit-on-borg server for all the changes matching the query terms.
493
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100494 WARNING: this is unreliable if a change matching the query is modified while
495 this function is being called.
496
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000497 A single query to gerrit-on-borg is limited on the number of results by the
498 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000500
501 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200502 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000503 limit: Maximum number of requested changes per query.
504 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100505 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000506
507 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100508 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000509 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 already_returned = set()
511 def at_most_once(cls):
512 for cl in cls:
513 if cl['_number'] not in already_returned:
514 already_returned.add(cl['_number'])
515 yield cl
516
517 start = start or 0
518 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000519 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100520
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000521 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100522 # This will fetch changes[start..start+limit] sorted by most recently
523 # updated. Since the rank of any change in this list can be changed any time
524 # (say user posting comment), subsequent calls may overalp like this:
525 # > initial order ABCDEFGH
526 # query[0..3] => ABC
527 # > E get's updated. New order: EABCDFGH
528 # query[3..6] => CDF # C is a dup
529 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200530 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100531 cur_start)
532 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000533 yield cl
534
535 more_changes = [cl for cl in page if '_more_changes' in cl]
536 if len(more_changes) > 1:
537 raise GerritError(
538 200,
539 'Received %d changes with a _more_changes attribute set but should '
540 'receive at most one.' % len(more_changes))
541 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100542 cur_start += len(page)
543
544 # If we paged through, query again the first page which in most circumstances
545 # will fetch all changes that were modified while this function was run.
546 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200547 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100548 for cl in at_most_once(page):
549 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000550
551
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200552def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100553 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000554 """Initiate a query composed of multiple sets of query parameters."""
555 if not change_list:
556 raise RuntimeError(
557 "MultiQueryChanges requires a list of change numbers/id's")
558 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200559 if params:
560 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000561 if limit:
562 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100563 if start:
564 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000565 if o_params:
566 q.extend(['o=%s' % p for p in o_params])
567 path = 'changes/?%s' % '&'.join(q)
568 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700569 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000570 except GerritError as e:
571 msg = '%s:\n%s' % (e.message, path)
572 raise GerritError(e.http_status, msg)
573 return result
574
575
576def GetGerritFetchUrl(host):
577 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
578 return '%s://%s/' % (GERRIT_PROTOCOL, host)
579
580
581def GetChangePageUrl(host, change_number):
582 """Given a gerrit host name and change number, return change page url."""
583 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
584
585
586def GetChangeUrl(host, change):
587 """Given a gerrit host name and change id, return an url for the change."""
588 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
589
590
591def GetChange(host, change):
592 """Query a gerrit server for information about a single change."""
593 path = 'changes/%s' % change
594 return ReadHttpJsonResponse(CreateHttpConn(host, path))
595
596
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700597def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000598 """Query a gerrit server for extended information about a single change."""
599 path = 'changes/%s/detail' % change
600 if o_params:
601 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700602 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000603
604
agable32978d92016-11-01 12:55:02 -0700605def GetChangeCommit(host, change, revision='current'):
606 """Query a gerrit server for a revision associated with a change."""
607 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
608 return ReadHttpJsonResponse(CreateHttpConn(host, path))
609
610
szager@chromium.orgb4696232013-10-16 19:45:35 +0000611def GetChangeCurrentRevision(host, change):
612 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200613 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000614
615
616def GetChangeRevisions(host, change):
617 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200618 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000619
620
621def GetChangeReview(host, change, revision=None):
622 """Get the current review information for a change."""
623 if not revision:
624 jmsg = GetChangeRevisions(host, change)
625 if not jmsg:
626 return None
627 elif len(jmsg) > 1:
628 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
629 revision = jmsg[0]['current_revision']
630 path = 'changes/%s/revisions/%s/review'
631 return ReadHttpJsonResponse(CreateHttpConn(host, path))
632
633
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700634def GetChangeComments(host, change):
635 """Get the line- and file-level comments on a change."""
636 path = 'changes/%s/comments' % change
637 return ReadHttpJsonResponse(CreateHttpConn(host, path))
638
639
szager@chromium.orgb4696232013-10-16 19:45:35 +0000640def AbandonChange(host, change, msg=''):
641 """Abandon a gerrit change."""
642 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000643 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000644 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
648def RestoreChange(host, change, msg=''):
649 """Restore a previously abandoned change."""
650 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000651 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000652 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700653 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000654
655
656def SubmitChange(host, change, wait_for_merge=True):
657 """Submits a gerrit change via Gerrit."""
658 path = 'changes/%s/submit' % change
659 body = {'wait_for_merge': wait_for_merge}
660 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700661 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000662
663
dsansomee2d6fd92016-09-08 00:10:47 -0700664def HasPendingChangeEdit(host, change):
665 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
666 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700667 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700668 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700669 # 204 No Content means no pending change.
670 if e.http_status == 204:
671 return False
672 raise
673 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700674
675
676def DeletePendingChangeEdit(host, change):
677 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700678 # On success, gerrit returns status 204; if the edit was already deleted it
679 # returns 404. Anything else is an error.
680 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700681
682
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100683def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000684 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700685 assert notify in ('ALL', 'NONE')
686 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700687 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700688 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000689 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700690 ReadHttpResponse(conn, accept_statuses=[200, 204])
691 except GerritError as e:
692 raise GerritError(
693 e.http_status,
694 'Received unexpected http status while editing message '
695 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000696
697
szager@chromium.orgb4696232013-10-16 19:45:35 +0000698def GetReviewers(host, change):
699 """Get information about all reviewers attached to a change."""
700 path = 'changes/%s/reviewers' % change
701 return ReadHttpJsonResponse(CreateHttpConn(host, path))
702
703
704def GetReview(host, change, revision):
705 """Get review information about a specific revision of a change."""
706 path = 'changes/%s/revisions/%s/review' % (change, revision)
707 return ReadHttpJsonResponse(CreateHttpConn(host, path))
708
709
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700710def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
711 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000712 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700713 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800714 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200715 if not change:
716 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700717 reviewers = frozenset(reviewers or [])
718 ccs = frozenset(ccs or [])
719 path = 'changes/%s/revisions/current/review' % change
720
721 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800722 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700723 'reviewers': [],
724 'notify': 'ALL' if notify else 'NONE',
725 }
726 for r in sorted(reviewers | ccs):
727 state = 'REVIEWER' if r in reviewers else 'CC'
728 body['reviewers'].append({
729 'reviewer': r,
730 'state': state,
731 'notify': 'NONE', # We handled `notify` argument above.
732 })
733
734 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
735 # Gerrit will return 400 if one or more of the requested reviewers are
736 # unprocessable. We read the response object to see which were rejected,
737 # warn about them, and retry with the remainder.
738 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
739
740 errored = set()
741 for result in resp.get('reviewers', {}).itervalues():
742 r = result.get('input')
743 state = 'REVIEWER' if r in reviewers else 'CC'
744 if result.get('error'):
745 errored.add(r)
746 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
747 if errored:
748 # Try again, adding only those that didn't fail, and only accepting 200.
749 AddReviewers(host, change, reviewers=(reviewers-errored),
750 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000751
752
753def RemoveReviewers(host, change, remove=None):
754 """Remove reveiewers from a change."""
755 if not remove:
756 return
757 if isinstance(remove, basestring):
758 remove = (remove,)
759 for r in remove:
760 path = 'changes/%s/reviewers/%s' % (change, r)
761 conn = CreateHttpConn(host, path, reqtype='DELETE')
762 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700763 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000764 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000765 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700766 e.http_status,
767 'Received unexpected http status while deleting reviewer "%s" '
768 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000769
770
Aaron Gable636b13f2017-07-14 10:42:48 -0700771def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000772 """Set labels and/or add a message to a code review."""
773 if not msg and not labels:
774 return
775 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800776 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000777 if msg:
778 body['message'] = msg
779 if labels:
780 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700781 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700782 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700783 if ready:
784 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000785 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
786 response = ReadHttpJsonResponse(conn)
787 if labels:
788 for key, val in labels.iteritems():
789 if ('labels' not in response or key not in response['labels'] or
790 int(response['labels'][key] != int(val))):
791 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
792 key, change))
793
794
795def ResetReviewLabels(host, change, label, value='0', message=None,
796 notify=None):
797 """Reset the value of a given label for all reviewers on a change."""
798 # This is tricky, because we want to work on the "current revision", but
799 # there's always the risk that "current revision" will change in between
800 # API calls. So, we check "current revision" at the beginning and end; if
801 # it has changed, raise an exception.
802 jmsg = GetChangeCurrentRevision(host, change)
803 if not jmsg:
804 raise GerritError(
805 200, 'Could not get review information for change "%s"' % change)
806 value = str(value)
807 revision = jmsg[0]['current_revision']
808 path = 'changes/%s/revisions/%s/review' % (change, revision)
809 message = message or (
810 '%s label set to %s programmatically.' % (label, value))
811 jmsg = GetReview(host, change, revision)
812 if not jmsg:
813 raise GerritError(200, 'Could not get review information for revison %s '
814 'of change %s' % (revision, change))
815 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
816 if str(review.get('value', value)) != value:
817 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800818 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000819 'message': message,
820 'labels': {label: value},
821 'on_behalf_of': review['_account_id'],
822 }
823 if notify:
824 body['notify'] = notify
825 conn = CreateHttpConn(
826 host, path, reqtype='POST', body=body)
827 response = ReadHttpJsonResponse(conn)
828 if str(response['labels'][label]) != value:
829 username = review.get('email', jmsg.get('name', ''))
830 raise GerritError(200, 'Unable to set %s label for user "%s"'
831 ' on change %s.' % (label, username, change))
832 jmsg = GetChangeCurrentRevision(host, change)
833 if not jmsg:
834 raise GerritError(
835 200, 'Could not get review information for change "%s"' % change)
836 elif jmsg[0]['current_revision'] != revision:
837 raise GerritError(200, 'While resetting labels on change "%s", '
838 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800839
840
dimu833c94c2017-01-18 17:36:15 -0800841def CreateGerritBranch(host, project, branch, commit):
842 """
843 Create a new branch from given project and commit
844 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
845
846 Returns:
847 A JSON with 'ref' key
848 """
849 path = 'projects/%s/branches/%s' % (project, branch)
850 body = {'revision': commit}
851 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700852 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800853 if response:
854 return response
855 raise GerritError(200, 'Unable to create gerrit branch')
856
857
858def GetGerritBranch(host, project, branch):
859 """
860 Get a branch from given project and commit
861 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
862
863 Returns:
864 A JSON object with 'revision' key
865 """
866 path = 'projects/%s/branches/%s' % (project, branch)
867 conn = CreateHttpConn(host, path, reqtype='GET')
868 response = ReadHttpJsonResponse(conn)
869 if response:
870 return response
871 raise GerritError(200, 'Unable to get gerrit branch')
872
873
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100874def GetAccountDetails(host, account_id='self'):
875 """Returns details of the account.
876
877 If account_id is not given, uses magic value 'self' which corresponds to
878 whichever account user is authenticating as.
879
880 Documentation:
881 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
882 """
883 if account_id != 'self':
884 account_id = int(account_id)
885 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
886 return ReadHttpJsonResponse(conn)
887
888
Nick Carter8692b182017-11-06 16:30:38 -0800889def PercentEncodeForGitRef(original):
890 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
891
892 The encoding used is based on but stricter than URL encoding (Section 2.1
893 of RFC 3986). The only non-escaped characters are alphanumerics, and
894 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
895 'PLUS SIGN' (U+002B).
896
897 For more information, see the Gerrit docs here:
898
899 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
900 """
901 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
902 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
903
904 # spaces are not allowed in git refs; gerrit will interpret either '_' or
905 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
906 return encoded.replace(' ', '_')
907
908
Dan Jacques8d11e482016-11-15 14:25:56 -0800909@contextlib.contextmanager
910def tempdir():
911 tdir = None
912 try:
913 tdir = tempfile.mkdtemp(suffix='gerrit_util')
914 yield tdir
915 finally:
916 if tdir:
917 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000918
919
920def ChangeIdentifier(project, change_number):
921 """Returns change identifier "project~number" suitable for |chagne| arg of
922 this module API.
923
924 Such format is allows for more efficient Gerrit routing of HTTP requests,
925 comparing to specifying just change_number.
926 """
927 assert int(change_number)
928 return '%s~%s' % (urllib.quote(project, safe=''), change_number)