blob: 2eeeeabb13c7f12c8ae8cb494397e7f481ebf8ed [file] [log] [blame]
szager@chromium.orgb4696232013-10-16 19:45:35 +00001# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""
6Utilities for requesting information for a gerrit server via https.
7
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
11import base64
Dan Jacques8d11e482016-11-15 14:25:56 -080012import contextlib
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000013import cookielib
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010014import httplib # Still used for its constants.
szager@chromium.orgb4696232013-10-16 19:45:35 +000015import json
16import logging
17import netrc
18import os
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000019import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000020import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000021import stat
22import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080023import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000024import time
25import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000026import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000027from cStringIO import StringIO
28
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070029import auth
Dan Jacques8d11e482016-11-15 14:25:56 -080030import gclient_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080031import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010032from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000033
szager@chromium.orgb4696232013-10-16 19:45:35 +000034LOGGER = logging.getLogger()
Steve Kobes56117722018-09-13 18:18:35 +000035# With a starting sleep time of 1.5 seconds, 2^n exponential backoff, and seven
36# total tries, the sleep time between the first and last tries will be 94.5 sec.
37# TODO(crbug.com/881860): Lower this when crbug.com/877717 is fixed.
38TRY_LIMIT = 7
szager@chromium.orgb4696232013-10-16 19:45:35 +000039
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000040
szager@chromium.orgb4696232013-10-16 19:45:35 +000041# Controls the transport protocol used to communicate with gerrit.
42# This is parameterized primarily to enable GerritTestCase.
43GERRIT_PROTOCOL = 'https'
44
45
46class GerritError(Exception):
47 """Exception class for errors commuicating with the gerrit-on-borg service."""
48 def __init__(self, http_status, *args, **kwargs):
49 super(GerritError, self).__init__(*args, **kwargs)
50 self.http_status = http_status
51 self.message = '(%d) %s' % (self.http_status, self.message)
52
53
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000054class GerritAuthenticationError(GerritError):
55 """Exception class for authentication errors during Gerrit communication."""
56
57
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020058def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000059 """Encodes query parameters in the key:val[+key:val...] format specified here:
60
61 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
62 """
63 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020064 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000065 return '+'.join(q)
66
67
Aaron Gabled2db5a22017-03-24 14:14:15 -070068def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000069 if protocol is None:
70 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010071 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070072 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000073 else:
74 raise RuntimeError(
75 "Don't know how to work with protocol '%s'" % protocol)
76
77
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000078class Authenticator(object):
79 """Base authenticator class for authenticator implementations to subclass."""
80
81 def get_auth_header(self, host):
82 raise NotImplementedError()
83
84 @staticmethod
85 def get():
86 """Returns: (Authenticator) The identified Authenticator to use.
87
88 Probes the local system and its environment and identifies the
89 Authenticator instance to use.
90 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070091 # LUCI Context takes priority since it's normally present only on bots,
92 # which then must use it.
93 if LuciContextAuthenticator.is_luci():
94 return LuciContextAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000095 if GceAuthenticator.is_gce():
96 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000097 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000098
99
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000100class CookiesAuthenticator(Authenticator):
101 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
102
103 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000104 """
105
106 def __init__(self):
107 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000108 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000109
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000110 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200111 def get_new_password_url(cls, host):
112 assert not host.startswith('http')
113 # Assume *.googlesource.com pattern.
114 parts = host.split('.')
115 if not parts[0].endswith('-review'):
116 parts[0] += '-review'
117 return 'https://%s/new-password' % ('.'.join(parts))
118
119 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000120 def get_new_password_message(cls, host):
121 assert not host.startswith('http')
122 # Assume *.googlesource.com pattern.
123 parts = host.split('.')
124 if not parts[0].endswith('-review'):
125 parts[0] += '-review'
126 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100127 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000128
129 @classmethod
130 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000131 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000132 return os.path.expanduser(os.path.join('~', path))
133
134 @classmethod
135 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800136 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000137 path = cls.get_netrc_path()
Sylvain Defresne2b138f72018-07-12 08:34:48 +0000138 if not os.path.exists(path):
139 return netrc.netrc(os.devnull)
140
141 st = os.stat(path)
142 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
143 print >> sys.stderr, (
144 'WARNING: netrc file %s cannot be used because its file '
145 'permissions are insecure. netrc file permissions should be '
146 '600.' % path)
147 with open(path) as fd:
148 content = fd.read()
Dan Jacques8d11e482016-11-15 14:25:56 -0800149
150 # Load the '.netrc' file. We strip comments from it because processing them
151 # can trigger a bug in Windows. See crbug.com/664664.
152 content = '\n'.join(l for l in content.splitlines()
153 if l.strip() and not l.strip().startswith('#'))
154 with tempdir() as tdir:
155 netrc_path = os.path.join(tdir, 'netrc')
156 with open(netrc_path, 'w') as fd:
157 fd.write(content)
158 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
159 return cls._get_netrc_from_path(netrc_path)
160
161 @classmethod
162 def _get_netrc_from_path(cls, path):
163 try:
164 return netrc.netrc(path)
165 except IOError:
166 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
167 return netrc.netrc(os.devnull)
168 except netrc.NetrcParseError as e:
169 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
170 'parsing error: %s' % (path, e))
171 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000172
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000173 @classmethod
174 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500175 if os.getenv('GIT_COOKIES_PATH'):
176 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800177 try:
178 return subprocess2.check_output(
179 ['git', 'config', '--path', 'http.cookiefile']).strip()
180 except subprocess2.CalledProcessError:
181 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000182
183 @classmethod
184 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000185 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000186 path = cls.get_gitcookies_path()
187 if not os.path.exists(path):
188 return gitcookies
189
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000190 try:
191 f = open(path, 'rb')
192 except IOError:
193 return gitcookies
194
195 with f:
196 for line in f:
197 try:
198 fields = line.strip().split('\t')
199 if line.strip().startswith('#') or len(fields) != 7:
200 continue
201 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
202 if xpath == '/' and key == 'o':
Eric Boren05263352018-09-18 16:54:45 +0000203 login, secret_token = value.split('=', 1)
204 gitcookies[domain] = (login, secret_token)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000205 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100206 LOGGER.warning(exc)
Eric Boren05263352018-09-18 16:54:45 +0000207
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000208 return gitcookies
209
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100210 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000211 for domain, creds in self.gitcookies.iteritems():
212 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100213 return (creds[0], None, creds[1])
214 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000215
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100216 def get_auth_header(self, host):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700217 a = self._get_auth_for_host(host)
218 if a:
Eric Boren05263352018-09-18 16:54:45 +0000219 return 'Basic %s' % (base64.b64encode('%s:%s' % (a[0], a[2])))
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000220 return None
221
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100222 def get_auth_email(self, host):
223 """Best effort parsing of email to be used for auth for the given host."""
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700224 a = self._get_auth_for_host(host)
225 if not a:
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100226 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700227 login = a[0]
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100228 # login typically looks like 'git-xxx.example.com'
229 if not login.startswith('git-') or '.' not in login:
230 return None
231 username, domain = login[len('git-'):].split('.', 1)
232 return '%s@%s' % (username, domain)
233
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100234
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000235# Backwards compatibility just in case somebody imports this outside of
236# depot_tools.
237NetrcAuthenticator = CookiesAuthenticator
238
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000239
240class GceAuthenticator(Authenticator):
241 """Authenticator implementation that uses GCE metadata service for token.
242 """
243
244 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700245 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
246 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000247 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
248
249 _cache_is_gce = None
250 _token_cache = None
251 _token_expiration = None
252
253 @classmethod
254 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500255 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
256 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000257 if cls._cache_is_gce is None:
258 cls._cache_is_gce = cls._test_is_gce()
259 return cls._cache_is_gce
260
261 @classmethod
262 def _test_is_gce(cls):
263 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
264 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100265 resp, _ = cls._get(cls._INFO_URL)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100266 except (socket.error, httplib2.ServerNotFoundError,
267 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000268 # Could not resolve URL.
269 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100270 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000271
272 @staticmethod
273 def _get(url, **kwargs):
274 next_delay_sec = 1
275 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000276 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700277 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100278 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000279 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
280 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100281 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000282
Aaron Gable92e9f382017-12-07 11:47:41 -0800283 # Retry server error status codes.
284 LOGGER.warn('Encountered server error')
285 if TRY_LIMIT - i > 1:
286 LOGGER.info('Will retry in %d seconds (%d more times)...',
287 next_delay_sec, TRY_LIMIT - i - 1)
288 time.sleep(next_delay_sec)
289 next_delay_sec *= 2
290
291
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000292 @classmethod
293 def _get_token_dict(cls):
294 if cls._token_cache:
295 # If it expires within 25 seconds, refresh.
296 if cls._token_expiration < time.time() - 25:
297 return cls._token_cache
298
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100299 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000300 if resp.status != httplib.OK:
301 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100302 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
304 return cls._token_cache
305
306 def get_auth_header(self, _host):
307 token_dict = self._get_token_dict()
308 if not token_dict:
309 return None
310 return '%(token_type)s %(access_token)s' % token_dict
311
312
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700313class LuciContextAuthenticator(Authenticator):
314 """Authenticator implementation that uses LUCI_CONTEXT ambient local auth.
315 """
316
317 @staticmethod
318 def is_luci():
319 return auth.has_luci_context_local_auth()
320
321 def __init__(self):
322 self._access_token = None
323 self._ensure_fresh()
324
325 def _ensure_fresh(self):
326 if not self._access_token or self._access_token.needs_refresh():
327 self._access_token = auth.get_luci_context_access_token(
328 scopes=' '.join([auth.OAUTH_SCOPE_EMAIL, auth.OAUTH_SCOPE_GERRIT]))
329
330 def get_auth_header(self, _host):
331 self._ensure_fresh()
332 return 'Bearer %s' % self._access_token.token
333
334
szager@chromium.orgb4696232013-10-16 19:45:35 +0000335def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
336 """Opens an https connection to a gerrit service, and sends a request."""
337 headers = headers or {}
338 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000339
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700340 a = Authenticator.get().get_auth_header(bare_host)
341 if a:
342 headers.setdefault('Authorization', a)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000343 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000344 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000345
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800346 url = path
347 if not url.startswith('/'):
348 url = '/' + url
349 if 'Authorization' in headers and not url.startswith('/a/'):
350 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000351
szager@chromium.orgb4696232013-10-16 19:45:35 +0000352 if body:
353 body = json.JSONEncoder().encode(body)
354 headers.setdefault('Content-Type', 'application/json')
355 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000356 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000357 for key, val in headers.iteritems():
358 if key == 'Authorization':
359 val = 'HIDDEN'
360 LOGGER.debug('%s: %s' % (key, val))
361 if body:
362 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700363 conn = GetConnectionObject()
Andrii Shyshkalov86c823e2018-09-18 19:51:33 +0000364 # HACK: httplib.Http has no such attribute; we store req_host here for later
365 # use in ReadHttpResponse.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000366 conn.req_host = host
367 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100368 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000369 'method': reqtype,
370 'headers': headers,
371 'body': body,
372 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000373 return conn
374
375
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700376def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000377 """Reads an http response from a connection into a string buffer.
378
379 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100380 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700381 accept_statuses: Treat any of these statuses as success. Default: [200]
382 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000383 Returns: A string buffer containing the connection's reply.
384 """
Steve Kobes56117722018-09-13 18:18:35 +0000385 sleep_time = 1.5
szager@chromium.orgb4696232013-10-16 19:45:35 +0000386 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100387 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000388
389 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100390 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000391 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
392 www_authenticate):
393 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
394 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700395 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000396 'has credentials for %s' % host)
397 raise GerritAuthenticationError(response.status, reason)
398
szager@chromium.orgb4696232013-10-16 19:45:35 +0000399 # If response.status < 500 then the result is final; break retry loop.
Michael Moss120b2e42018-06-21 23:53:42 +0000400 # If the response is 404/409, it might be because of replication lag, so
Aaron Gable62ca9602017-05-19 17:24:52 -0700401 # keep trying anyway.
Michael Moss120b2e42018-06-21 23:53:42 +0000402 if ((response.status < 500 and response.status not in [404, 409])
Michael Mossb40a4512017-10-10 11:07:17 -0700403 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100404 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100405 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700406 # If 404 was in accept_statuses, then it's expected that the file might
407 # not exist, so don't return the gitiles error page because that's not the
408 # "content" that was actually requested.
409 if response.status == 404:
410 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000411 break
412 # A status >=500 is assumed to be a possible transient error; retry.
413 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalovea4301e2018-09-17 18:48:23 +0000414
415 # TODO(crbug/881860): remove this special 404 handling.
416 if response.status == 404:
417 LOGGER.warn(
418 '404 NotFound error occurred while querying %s %s: %s\n'
419 'NOTE: if see this while running `git cl upload`,\n'
420 'consider reporting this to https://crbug.com/881860.\n'
421 'Please, include response headers below:\n'
422 ' %s\n',
423 conn.req_params['method'], conn.req_params['uri'], response.reason,
424 '\n '.join(
425 json.dumps(response, sort_keys=True, indent=2).splitlines()))
426 else:
427 LOGGER.warn('A transient error occurred while querying %s:\n'
428 '%s %s\n'
429 '%s %d %s\n',
430 conn.req_host,
431 conn.req_params['method'], conn.req_params['uri'],
432 http_version, response.status, response.reason)
433
szager@chromium.orgb4696232013-10-16 19:45:35 +0000434 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800435 LOGGER.info('Will retry in %d seconds (%d more times)...',
436 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000437 time.sleep(sleep_time)
438 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700439 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200440 if response.status in (401, 403):
441 print('Your Gerrit credentials might be misconfigured. Try: \n'
442 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100443 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000444 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100445 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000446
447
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700448def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000449 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700450 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000451 # The first line of the response should always be: )]}'
452 s = fh.readline()
453 if s and s.rstrip() != ")]}'":
454 raise GerritError(200, 'Unexpected json output: %s' % s)
455 s = fh.read()
456 if not s:
457 return None
458 return json.loads(s)
459
460
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200461def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100462 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000463 """
464 Queries a gerrit-on-borg server for changes matching query terms.
465
466 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200467 params: A list of key:value pairs for search parameters, as documented
468 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
469 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000470 first_param: A change identifier
471 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100472 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000473 o_params: A list of additional output specifiers, as documented here:
474 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
475 Returns:
476 A list of json-decoded query results.
477 """
478 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200479 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000480 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200481 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100482 if start:
483 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000484 if limit:
485 path = '%s&n=%d' % (path, limit)
486 if o_params:
487 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700488 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000489
490
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200491def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100492 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000493 """
494 Queries a gerrit-on-borg server for all the changes matching the query terms.
495
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100496 WARNING: this is unreliable if a change matching the query is modified while
497 this function is being called.
498
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000499 A single query to gerrit-on-borg is limited on the number of results by the
500 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100501 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000502
503 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200504 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000505 limit: Maximum number of requested changes per query.
506 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100507 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000508
509 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000511 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100512 already_returned = set()
513 def at_most_once(cls):
514 for cl in cls:
515 if cl['_number'] not in already_returned:
516 already_returned.add(cl['_number'])
517 yield cl
518
519 start = start or 0
520 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000521 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100522
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000523 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100524 # This will fetch changes[start..start+limit] sorted by most recently
525 # updated. Since the rank of any change in this list can be changed any time
526 # (say user posting comment), subsequent calls may overalp like this:
527 # > initial order ABCDEFGH
528 # query[0..3] => ABC
529 # > E get's updated. New order: EABCDFGH
530 # query[3..6] => CDF # C is a dup
531 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200532 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100533 cur_start)
534 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000535 yield cl
536
537 more_changes = [cl for cl in page if '_more_changes' in cl]
538 if len(more_changes) > 1:
539 raise GerritError(
540 200,
541 'Received %d changes with a _more_changes attribute set but should '
542 'receive at most one.' % len(more_changes))
543 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100544 cur_start += len(page)
545
546 # If we paged through, query again the first page which in most circumstances
547 # will fetch all changes that were modified while this function was run.
548 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200549 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100550 for cl in at_most_once(page):
551 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000552
553
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200554def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100555 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000556 """Initiate a query composed of multiple sets of query parameters."""
557 if not change_list:
558 raise RuntimeError(
559 "MultiQueryChanges requires a list of change numbers/id's")
560 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200561 if params:
562 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000563 if limit:
564 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100565 if start:
566 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000567 if o_params:
568 q.extend(['o=%s' % p for p in o_params])
569 path = 'changes/?%s' % '&'.join(q)
570 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700571 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000572 except GerritError as e:
573 msg = '%s:\n%s' % (e.message, path)
574 raise GerritError(e.http_status, msg)
575 return result
576
577
578def GetGerritFetchUrl(host):
579 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
580 return '%s://%s/' % (GERRIT_PROTOCOL, host)
581
582
583def GetChangePageUrl(host, change_number):
584 """Given a gerrit host name and change number, return change page url."""
585 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
586
587
588def GetChangeUrl(host, change):
589 """Given a gerrit host name and change id, return an url for the change."""
590 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
591
592
593def GetChange(host, change):
594 """Query a gerrit server for information about a single change."""
595 path = 'changes/%s' % change
596 return ReadHttpJsonResponse(CreateHttpConn(host, path))
597
598
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700599def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000600 """Query a gerrit server for extended information about a single change."""
601 path = 'changes/%s/detail' % change
602 if o_params:
603 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700604 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000605
606
agable32978d92016-11-01 12:55:02 -0700607def GetChangeCommit(host, change, revision='current'):
608 """Query a gerrit server for a revision associated with a change."""
609 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
610 return ReadHttpJsonResponse(CreateHttpConn(host, path))
611
612
szager@chromium.orgb4696232013-10-16 19:45:35 +0000613def GetChangeCurrentRevision(host, change):
614 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200615 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000616
617
618def GetChangeRevisions(host, change):
619 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200620 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000621
622
623def GetChangeReview(host, change, revision=None):
624 """Get the current review information for a change."""
625 if not revision:
626 jmsg = GetChangeRevisions(host, change)
627 if not jmsg:
628 return None
629 elif len(jmsg) > 1:
630 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
631 revision = jmsg[0]['current_revision']
632 path = 'changes/%s/revisions/%s/review'
633 return ReadHttpJsonResponse(CreateHttpConn(host, path))
634
635
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700636def GetChangeComments(host, change):
637 """Get the line- and file-level comments on a change."""
638 path = 'changes/%s/comments' % change
639 return ReadHttpJsonResponse(CreateHttpConn(host, path))
640
641
szager@chromium.orgb4696232013-10-16 19:45:35 +0000642def AbandonChange(host, change, msg=''):
643 """Abandon a gerrit change."""
644 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000645 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000646 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
650def RestoreChange(host, change, msg=''):
651 """Restore a previously abandoned change."""
652 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000653 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000654 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700655 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000656
657
658def SubmitChange(host, change, wait_for_merge=True):
659 """Submits a gerrit change via Gerrit."""
660 path = 'changes/%s/submit' % change
661 body = {'wait_for_merge': wait_for_merge}
662 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700663 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664
665
dsansomee2d6fd92016-09-08 00:10:47 -0700666def HasPendingChangeEdit(host, change):
667 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
668 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700669 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700670 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700671 # 204 No Content means no pending change.
672 if e.http_status == 204:
673 return False
674 raise
675 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700676
677
678def DeletePendingChangeEdit(host, change):
679 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700680 # On success, gerrit returns status 204; if the edit was already deleted it
681 # returns 404. Anything else is an error.
682 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700683
684
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100685def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000686 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700687 assert notify in ('ALL', 'NONE')
688 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700689 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700690 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000691 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700692 ReadHttpResponse(conn, accept_statuses=[200, 204])
693 except GerritError as e:
694 raise GerritError(
695 e.http_status,
696 'Received unexpected http status while editing message '
697 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000698
699
szager@chromium.orgb4696232013-10-16 19:45:35 +0000700def GetReviewers(host, change):
701 """Get information about all reviewers attached to a change."""
702 path = 'changes/%s/reviewers' % change
703 return ReadHttpJsonResponse(CreateHttpConn(host, path))
704
705
706def GetReview(host, change, revision):
707 """Get review information about a specific revision of a change."""
708 path = 'changes/%s/revisions/%s/review' % (change, revision)
709 return ReadHttpJsonResponse(CreateHttpConn(host, path))
710
711
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700712def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
713 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000714 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700715 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800716 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200717 if not change:
718 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700719 reviewers = frozenset(reviewers or [])
720 ccs = frozenset(ccs or [])
721 path = 'changes/%s/revisions/current/review' % change
722
723 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800724 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700725 'reviewers': [],
726 'notify': 'ALL' if notify else 'NONE',
727 }
728 for r in sorted(reviewers | ccs):
729 state = 'REVIEWER' if r in reviewers else 'CC'
730 body['reviewers'].append({
731 'reviewer': r,
732 'state': state,
733 'notify': 'NONE', # We handled `notify` argument above.
734 })
735
736 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
737 # Gerrit will return 400 if one or more of the requested reviewers are
738 # unprocessable. We read the response object to see which were rejected,
739 # warn about them, and retry with the remainder.
740 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
741
742 errored = set()
743 for result in resp.get('reviewers', {}).itervalues():
744 r = result.get('input')
745 state = 'REVIEWER' if r in reviewers else 'CC'
746 if result.get('error'):
747 errored.add(r)
748 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
749 if errored:
750 # Try again, adding only those that didn't fail, and only accepting 200.
751 AddReviewers(host, change, reviewers=(reviewers-errored),
752 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000753
754
755def RemoveReviewers(host, change, remove=None):
756 """Remove reveiewers from a change."""
757 if not remove:
758 return
759 if isinstance(remove, basestring):
760 remove = (remove,)
761 for r in remove:
762 path = 'changes/%s/reviewers/%s' % (change, r)
763 conn = CreateHttpConn(host, path, reqtype='DELETE')
764 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700765 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000766 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000767 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700768 e.http_status,
769 'Received unexpected http status while deleting reviewer "%s" '
770 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000771
772
Aaron Gable636b13f2017-07-14 10:42:48 -0700773def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000774 """Set labels and/or add a message to a code review."""
775 if not msg and not labels:
776 return
777 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800778 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000779 if msg:
780 body['message'] = msg
781 if labels:
782 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700783 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700784 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700785 if ready:
786 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000787 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
788 response = ReadHttpJsonResponse(conn)
789 if labels:
790 for key, val in labels.iteritems():
791 if ('labels' not in response or key not in response['labels'] or
792 int(response['labels'][key] != int(val))):
793 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
794 key, change))
795
796
797def ResetReviewLabels(host, change, label, value='0', message=None,
798 notify=None):
799 """Reset the value of a given label for all reviewers on a change."""
800 # This is tricky, because we want to work on the "current revision", but
801 # there's always the risk that "current revision" will change in between
802 # API calls. So, we check "current revision" at the beginning and end; if
803 # it has changed, raise an exception.
804 jmsg = GetChangeCurrentRevision(host, change)
805 if not jmsg:
806 raise GerritError(
807 200, 'Could not get review information for change "%s"' % change)
808 value = str(value)
809 revision = jmsg[0]['current_revision']
810 path = 'changes/%s/revisions/%s/review' % (change, revision)
811 message = message or (
812 '%s label set to %s programmatically.' % (label, value))
813 jmsg = GetReview(host, change, revision)
814 if not jmsg:
815 raise GerritError(200, 'Could not get review information for revison %s '
816 'of change %s' % (revision, change))
817 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
818 if str(review.get('value', value)) != value:
819 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800820 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000821 'message': message,
822 'labels': {label: value},
823 'on_behalf_of': review['_account_id'],
824 }
825 if notify:
826 body['notify'] = notify
827 conn = CreateHttpConn(
828 host, path, reqtype='POST', body=body)
829 response = ReadHttpJsonResponse(conn)
830 if str(response['labels'][label]) != value:
831 username = review.get('email', jmsg.get('name', ''))
832 raise GerritError(200, 'Unable to set %s label for user "%s"'
833 ' on change %s.' % (label, username, change))
834 jmsg = GetChangeCurrentRevision(host, change)
835 if not jmsg:
836 raise GerritError(
837 200, 'Could not get review information for change "%s"' % change)
838 elif jmsg[0]['current_revision'] != revision:
839 raise GerritError(200, 'While resetting labels on change "%s", '
840 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800841
842
dimu833c94c2017-01-18 17:36:15 -0800843def CreateGerritBranch(host, project, branch, commit):
844 """
845 Create a new branch from given project and commit
846 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
847
848 Returns:
849 A JSON with 'ref' key
850 """
851 path = 'projects/%s/branches/%s' % (project, branch)
852 body = {'revision': commit}
853 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700854 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800855 if response:
856 return response
857 raise GerritError(200, 'Unable to create gerrit branch')
858
859
860def GetGerritBranch(host, project, branch):
861 """
862 Get a branch from given project and commit
863 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
864
865 Returns:
866 A JSON object with 'revision' key
867 """
868 path = 'projects/%s/branches/%s' % (project, branch)
869 conn = CreateHttpConn(host, path, reqtype='GET')
870 response = ReadHttpJsonResponse(conn)
871 if response:
872 return response
873 raise GerritError(200, 'Unable to get gerrit branch')
874
875
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100876def GetAccountDetails(host, account_id='self'):
877 """Returns details of the account.
878
879 If account_id is not given, uses magic value 'self' which corresponds to
880 whichever account user is authenticating as.
881
882 Documentation:
883 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
884 """
885 if account_id != 'self':
886 account_id = int(account_id)
887 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
888 return ReadHttpJsonResponse(conn)
889
890
Nick Carter8692b182017-11-06 16:30:38 -0800891def PercentEncodeForGitRef(original):
892 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
893
894 The encoding used is based on but stricter than URL encoding (Section 2.1
895 of RFC 3986). The only non-escaped characters are alphanumerics, and
896 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
897 'PLUS SIGN' (U+002B).
898
899 For more information, see the Gerrit docs here:
900
901 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
902 """
903 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
904 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
905
906 # spaces are not allowed in git refs; gerrit will interpret either '_' or
907 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
908 return encoded.replace(' ', '_')
909
910
Dan Jacques8d11e482016-11-15 14:25:56 -0800911@contextlib.contextmanager
912def tempdir():
913 tdir = None
914 try:
915 tdir = tempfile.mkdtemp(suffix='gerrit_util')
916 yield tdir
917 finally:
918 if tdir:
919 gclient_utils.rmtree(tdir)
Andrii Shyshkalov0ec9d152018-08-23 00:22:58 +0000920
921
922def ChangeIdentifier(project, change_number):
923 """Returns change identifier "project~number" suitable for |chagne| arg of
924 this module API.
925
926 Such format is allows for more efficient Gerrit routing of HTTP requests,
927 comparing to specifying just change_number.
928 """
929 assert int(change_number)
930 return '%s~%s' % (urllib.quote(project, safe=''), change_number)