blob: 6bebdea19720c4147a72373974c6ba5b96ac7135 [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
Dan Jacques8d11e482016-11-15 14:25:56 -080029import gclient_utils
Aaron Gable8797cab2018-03-06 13:55:00 -080030import subprocess2
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010031from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000032
szager@chromium.orgb4696232013-10-16 19:45:35 +000033LOGGER = logging.getLogger()
Aaron Gable92e9f382017-12-07 11:47:41 -080034# With a starting sleep time of 1 second, 2^n exponential backoff, and six
35# total tries, the sleep time between the first and last tries will be 31s.
36TRY_LIMIT = 6
szager@chromium.orgb4696232013-10-16 19:45:35 +000037
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000038
szager@chromium.orgb4696232013-10-16 19:45:35 +000039# Controls the transport protocol used to communicate with gerrit.
40# This is parameterized primarily to enable GerritTestCase.
41GERRIT_PROTOCOL = 'https'
42
43
44class GerritError(Exception):
45 """Exception class for errors commuicating with the gerrit-on-borg service."""
46 def __init__(self, http_status, *args, **kwargs):
47 super(GerritError, self).__init__(*args, **kwargs)
48 self.http_status = http_status
49 self.message = '(%d) %s' % (self.http_status, self.message)
50
51
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000052class GerritAuthenticationError(GerritError):
53 """Exception class for authentication errors during Gerrit communication."""
54
55
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020056def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000057 """Encodes query parameters in the key:val[+key:val...] format specified here:
58
59 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
60 """
61 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020062 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000063 return '+'.join(q)
64
65
Aaron Gabled2db5a22017-03-24 14:14:15 -070066def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000067 if protocol is None:
68 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010069 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070070 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000071 else:
72 raise RuntimeError(
73 "Don't know how to work with protocol '%s'" % protocol)
74
75
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000076class Authenticator(object):
77 """Base authenticator class for authenticator implementations to subclass."""
78
79 def get_auth_header(self, host):
80 raise NotImplementedError()
81
82 @staticmethod
83 def get():
84 """Returns: (Authenticator) The identified Authenticator to use.
85
86 Probes the local system and its environment and identifies the
87 Authenticator instance to use.
88 """
89 if GceAuthenticator.is_gce():
90 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000091 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000092
93
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000094class CookiesAuthenticator(Authenticator):
95 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
96
97 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000098 """
99
100 def __init__(self):
101 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000102 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000103
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000104 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200105 def get_new_password_url(cls, host):
106 assert not host.startswith('http')
107 # Assume *.googlesource.com pattern.
108 parts = host.split('.')
109 if not parts[0].endswith('-review'):
110 parts[0] += '-review'
111 return 'https://%s/new-password' % ('.'.join(parts))
112
113 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000114 def get_new_password_message(cls, host):
115 assert not host.startswith('http')
116 # Assume *.googlesource.com pattern.
117 parts = host.split('.')
118 if not parts[0].endswith('-review'):
119 parts[0] += '-review'
120 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100121 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000122
123 @classmethod
124 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000125 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000126 return os.path.expanduser(os.path.join('~', path))
127
128 @classmethod
129 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800130 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000131 path = cls.get_netrc_path()
Dan Jacques8d11e482016-11-15 14:25:56 -0800132 content = ''
133 if os.path.exists(path):
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000134 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000135 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
136 print >> sys.stderr, (
137 'WARNING: netrc file %s cannot be used because its file '
138 'permissions are insecure. netrc file permissions should be '
139 '600.' % path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800140 with open(path) as fd:
141 content = fd.read()
142
143 # Load the '.netrc' file. We strip comments from it because processing them
144 # can trigger a bug in Windows. See crbug.com/664664.
145 content = '\n'.join(l for l in content.splitlines()
146 if l.strip() and not l.strip().startswith('#'))
147 with tempdir() as tdir:
148 netrc_path = os.path.join(tdir, 'netrc')
149 with open(netrc_path, 'w') as fd:
150 fd.write(content)
151 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
152 return cls._get_netrc_from_path(netrc_path)
153
154 @classmethod
155 def _get_netrc_from_path(cls, path):
156 try:
157 return netrc.netrc(path)
158 except IOError:
159 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
160 return netrc.netrc(os.devnull)
161 except netrc.NetrcParseError as e:
162 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
163 'parsing error: %s' % (path, e))
164 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000165
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000166 @classmethod
167 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500168 if os.getenv('GIT_COOKIES_PATH'):
169 return os.getenv('GIT_COOKIES_PATH')
Aaron Gable8797cab2018-03-06 13:55:00 -0800170 try:
171 return subprocess2.check_output(
172 ['git', 'config', '--path', 'http.cookiefile']).strip()
173 except subprocess2.CalledProcessError:
174 return os.path.join(os.environ['HOME'], '.gitcookies')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000175
176 @classmethod
177 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000178 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000179 path = cls.get_gitcookies_path()
180 if not os.path.exists(path):
181 return gitcookies
182
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000183 try:
184 f = open(path, 'rb')
185 except IOError:
186 return gitcookies
187
188 with f:
189 for line in f:
190 try:
191 fields = line.strip().split('\t')
192 if line.strip().startswith('#') or len(fields) != 7:
193 continue
194 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
195 if xpath == '/' and key == 'o':
196 login, secret_token = value.split('=', 1)
197 gitcookies[domain] = (login, secret_token)
198 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100199 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000200
201 return gitcookies
202
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100203 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000204 for domain, creds in self.gitcookies.iteritems():
205 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100206 return (creds[0], None, creds[1])
207 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000208
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100209 def get_auth_header(self, host):
210 auth = self._get_auth_for_host(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000211 if auth:
212 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
213 return None
214
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100215 def get_auth_email(self, host):
216 """Best effort parsing of email to be used for auth for the given host."""
217 auth = self._get_auth_for_host(host)
218 if not auth:
219 return None
220 login = auth[0]
221 # login typically looks like 'git-xxx.example.com'
222 if not login.startswith('git-') or '.' not in login:
223 return None
224 username, domain = login[len('git-'):].split('.', 1)
225 return '%s@%s' % (username, domain)
226
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100227
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000228# Backwards compatibility just in case somebody imports this outside of
229# depot_tools.
230NetrcAuthenticator = CookiesAuthenticator
231
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000232
233class GceAuthenticator(Authenticator):
234 """Authenticator implementation that uses GCE metadata service for token.
235 """
236
237 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700238 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
239 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000240 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
241
242 _cache_is_gce = None
243 _token_cache = None
244 _token_expiration = None
245
246 @classmethod
247 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500248 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
249 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000250 if cls._cache_is_gce is None:
251 cls._cache_is_gce = cls._test_is_gce()
252 return cls._cache_is_gce
253
254 @classmethod
255 def _test_is_gce(cls):
256 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
257 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100258 resp, _ = cls._get(cls._INFO_URL)
259 except (socket.error, httplib2.ServerNotFoundError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000260 # Could not resolve URL.
261 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100262 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000263
264 @staticmethod
265 def _get(url, **kwargs):
266 next_delay_sec = 1
267 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000268 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700269 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100270 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000271 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
272 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100273 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000274
Aaron Gable92e9f382017-12-07 11:47:41 -0800275 # Retry server error status codes.
276 LOGGER.warn('Encountered server error')
277 if TRY_LIMIT - i > 1:
278 LOGGER.info('Will retry in %d seconds (%d more times)...',
279 next_delay_sec, TRY_LIMIT - i - 1)
280 time.sleep(next_delay_sec)
281 next_delay_sec *= 2
282
283
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000284 @classmethod
285 def _get_token_dict(cls):
286 if cls._token_cache:
287 # If it expires within 25 seconds, refresh.
288 if cls._token_expiration < time.time() - 25:
289 return cls._token_cache
290
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100291 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000292 if resp.status != httplib.OK:
293 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100294 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000295 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
296 return cls._token_cache
297
298 def get_auth_header(self, _host):
299 token_dict = self._get_token_dict()
300 if not token_dict:
301 return None
302 return '%(token_type)s %(access_token)s' % token_dict
303
304
szager@chromium.orgb4696232013-10-16 19:45:35 +0000305def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
306 """Opens an https connection to a gerrit service, and sends a request."""
307 headers = headers or {}
308 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000309
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000310 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000311 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000312 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000313 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000314 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000315
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800316 url = path
317 if not url.startswith('/'):
318 url = '/' + url
319 if 'Authorization' in headers and not url.startswith('/a/'):
320 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000321
szager@chromium.orgb4696232013-10-16 19:45:35 +0000322 if body:
323 body = json.JSONEncoder().encode(body)
324 headers.setdefault('Content-Type', 'application/json')
325 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000326 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000327 for key, val in headers.iteritems():
328 if key == 'Authorization':
329 val = 'HIDDEN'
330 LOGGER.debug('%s: %s' % (key, val))
331 if body:
332 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700333 conn = GetConnectionObject()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000334 conn.req_host = host
335 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100336 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000337 'method': reqtype,
338 'headers': headers,
339 'body': body,
340 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000341 return conn
342
343
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700344def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000345 """Reads an http response from a connection into a string buffer.
346
347 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100348 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700349 accept_statuses: Treat any of these statuses as success. Default: [200]
350 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000351 Returns: A string buffer containing the connection's reply.
352 """
Aaron Gable92e9f382017-12-07 11:47:41 -0800353 sleep_time = 1
szager@chromium.orgb4696232013-10-16 19:45:35 +0000354 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100355 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000356
357 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100358 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000359 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
360 www_authenticate):
361 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
362 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700363 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000364 'has credentials for %s' % host)
365 raise GerritAuthenticationError(response.status, reason)
366
szager@chromium.orgb4696232013-10-16 19:45:35 +0000367 # If response.status < 500 then the result is final; break retry loop.
Aaron Gable62ca9602017-05-19 17:24:52 -0700368 # If the response is 404, it might be because of replication lag, so
369 # keep trying anyway.
Michael Mossb40a4512017-10-10 11:07:17 -0700370 if ((response.status < 500 and response.status != 404)
371 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100372 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100373 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700374 # If 404 was in accept_statuses, then it's expected that the file might
375 # not exist, so don't return the gitiles error page because that's not the
376 # "content" that was actually requested.
377 if response.status == 404:
378 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000379 break
380 # A status >=500 is assumed to be a possible transient error; retry.
381 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100382 LOGGER.warn('A transient error occurred while querying %s:\n'
383 '%s %s %s\n'
384 '%s %d %s',
Aaron Gable20d2cbb2017-04-25 15:04:05 -0700385 conn.req_host, conn.req_params['method'],
386 conn.req_params['uri'],
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100387 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000388 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800389 LOGGER.info('Will retry in %d seconds (%d more times)...',
390 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000391 time.sleep(sleep_time)
392 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700393 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200394 if response.status in (401, 403):
395 print('Your Gerrit credentials might be misconfigured. Try: \n'
396 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100397 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000398 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100399 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000400
401
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700402def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000403 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700404 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000405 # The first line of the response should always be: )]}'
406 s = fh.readline()
407 if s and s.rstrip() != ")]}'":
408 raise GerritError(200, 'Unexpected json output: %s' % s)
409 s = fh.read()
410 if not s:
411 return None
412 return json.loads(s)
413
414
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200415def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100416 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000417 """
418 Queries a gerrit-on-borg server for changes matching query terms.
419
420 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200421 params: A list of key:value pairs for search parameters, as documented
422 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
423 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000424 first_param: A change identifier
425 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100426 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000427 o_params: A list of additional output specifiers, as documented here:
428 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
429 Returns:
430 A list of json-decoded query results.
431 """
432 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200433 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000434 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200435 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100436 if start:
437 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000438 if limit:
439 path = '%s&n=%d' % (path, limit)
440 if o_params:
441 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700442 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000443
444
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200445def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100446 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000447 """
448 Queries a gerrit-on-borg server for all the changes matching the query terms.
449
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100450 WARNING: this is unreliable if a change matching the query is modified while
451 this function is being called.
452
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000453 A single query to gerrit-on-borg is limited on the number of results by the
454 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100455 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000456
457 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200458 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000459 limit: Maximum number of requested changes per query.
460 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100461 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000462
463 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100464 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000465 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100466 already_returned = set()
467 def at_most_once(cls):
468 for cl in cls:
469 if cl['_number'] not in already_returned:
470 already_returned.add(cl['_number'])
471 yield cl
472
473 start = start or 0
474 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000475 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100476
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000477 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100478 # This will fetch changes[start..start+limit] sorted by most recently
479 # updated. Since the rank of any change in this list can be changed any time
480 # (say user posting comment), subsequent calls may overalp like this:
481 # > initial order ABCDEFGH
482 # query[0..3] => ABC
483 # > E get's updated. New order: EABCDFGH
484 # query[3..6] => CDF # C is a dup
485 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200486 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100487 cur_start)
488 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000489 yield cl
490
491 more_changes = [cl for cl in page if '_more_changes' in cl]
492 if len(more_changes) > 1:
493 raise GerritError(
494 200,
495 'Received %d changes with a _more_changes attribute set but should '
496 'receive at most one.' % len(more_changes))
497 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100498 cur_start += len(page)
499
500 # If we paged through, query again the first page which in most circumstances
501 # will fetch all changes that were modified while this function was run.
502 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200503 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100504 for cl in at_most_once(page):
505 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000506
507
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200508def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100509 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000510 """Initiate a query composed of multiple sets of query parameters."""
511 if not change_list:
512 raise RuntimeError(
513 "MultiQueryChanges requires a list of change numbers/id's")
514 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200515 if params:
516 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000517 if limit:
518 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100519 if start:
520 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000521 if o_params:
522 q.extend(['o=%s' % p for p in o_params])
523 path = 'changes/?%s' % '&'.join(q)
524 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700525 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000526 except GerritError as e:
527 msg = '%s:\n%s' % (e.message, path)
528 raise GerritError(e.http_status, msg)
529 return result
530
531
532def GetGerritFetchUrl(host):
533 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
534 return '%s://%s/' % (GERRIT_PROTOCOL, host)
535
536
537def GetChangePageUrl(host, change_number):
538 """Given a gerrit host name and change number, return change page url."""
539 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
540
541
542def GetChangeUrl(host, change):
543 """Given a gerrit host name and change id, return an url for the change."""
544 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
545
546
547def GetChange(host, change):
548 """Query a gerrit server for information about a single change."""
549 path = 'changes/%s' % change
550 return ReadHttpJsonResponse(CreateHttpConn(host, path))
551
552
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700553def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000554 """Query a gerrit server for extended information about a single change."""
555 path = 'changes/%s/detail' % change
556 if o_params:
557 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700558 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000559
560
agable32978d92016-11-01 12:55:02 -0700561def GetChangeCommit(host, change, revision='current'):
562 """Query a gerrit server for a revision associated with a change."""
563 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
564 return ReadHttpJsonResponse(CreateHttpConn(host, path))
565
566
szager@chromium.orgb4696232013-10-16 19:45:35 +0000567def GetChangeCurrentRevision(host, change):
568 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200569 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000570
571
572def GetChangeRevisions(host, change):
573 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200574 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000575
576
577def GetChangeReview(host, change, revision=None):
578 """Get the current review information for a change."""
579 if not revision:
580 jmsg = GetChangeRevisions(host, change)
581 if not jmsg:
582 return None
583 elif len(jmsg) > 1:
584 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
585 revision = jmsg[0]['current_revision']
586 path = 'changes/%s/revisions/%s/review'
587 return ReadHttpJsonResponse(CreateHttpConn(host, path))
588
589
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700590def GetChangeComments(host, change):
591 """Get the line- and file-level comments on a change."""
592 path = 'changes/%s/comments' % change
593 return ReadHttpJsonResponse(CreateHttpConn(host, path))
594
595
szager@chromium.orgb4696232013-10-16 19:45:35 +0000596def AbandonChange(host, change, msg=''):
597 """Abandon a gerrit change."""
598 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000599 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000600 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700601 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000602
603
604def RestoreChange(host, change, msg=''):
605 """Restore a previously abandoned change."""
606 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000607 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000608 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700609 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000610
611
612def SubmitChange(host, change, wait_for_merge=True):
613 """Submits a gerrit change via Gerrit."""
614 path = 'changes/%s/submit' % change
615 body = {'wait_for_merge': wait_for_merge}
616 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700617 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000618
619
dsansomee2d6fd92016-09-08 00:10:47 -0700620def HasPendingChangeEdit(host, change):
621 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
622 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700623 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700624 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700625 # 204 No Content means no pending change.
626 if e.http_status == 204:
627 return False
628 raise
629 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700630
631
632def DeletePendingChangeEdit(host, change):
633 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700634 # On success, gerrit returns status 204; if the edit was already deleted it
635 # returns 404. Anything else is an error.
636 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700637
638
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100639def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000640 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700641 assert notify in ('ALL', 'NONE')
642 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700643 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700644 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000645 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700646 ReadHttpResponse(conn, accept_statuses=[200, 204])
647 except GerritError as e:
648 raise GerritError(
649 e.http_status,
650 'Received unexpected http status while editing message '
651 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000652
653
szager@chromium.orgb4696232013-10-16 19:45:35 +0000654def GetReviewers(host, change):
655 """Get information about all reviewers attached to a change."""
656 path = 'changes/%s/reviewers' % change
657 return ReadHttpJsonResponse(CreateHttpConn(host, path))
658
659
660def GetReview(host, change, revision):
661 """Get review information about a specific revision of a change."""
662 path = 'changes/%s/revisions/%s/review' % (change, revision)
663 return ReadHttpJsonResponse(CreateHttpConn(host, path))
664
665
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700666def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
667 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000668 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700669 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800670 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200671 if not change:
672 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700673 reviewers = frozenset(reviewers or [])
674 ccs = frozenset(ccs or [])
675 path = 'changes/%s/revisions/current/review' % change
676
677 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800678 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700679 'reviewers': [],
680 'notify': 'ALL' if notify else 'NONE',
681 }
682 for r in sorted(reviewers | ccs):
683 state = 'REVIEWER' if r in reviewers else 'CC'
684 body['reviewers'].append({
685 'reviewer': r,
686 'state': state,
687 'notify': 'NONE', # We handled `notify` argument above.
688 })
689
690 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
691 # Gerrit will return 400 if one or more of the requested reviewers are
692 # unprocessable. We read the response object to see which were rejected,
693 # warn about them, and retry with the remainder.
694 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
695
696 errored = set()
697 for result in resp.get('reviewers', {}).itervalues():
698 r = result.get('input')
699 state = 'REVIEWER' if r in reviewers else 'CC'
700 if result.get('error'):
701 errored.add(r)
702 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
703 if errored:
704 # Try again, adding only those that didn't fail, and only accepting 200.
705 AddReviewers(host, change, reviewers=(reviewers-errored),
706 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000707
708
709def RemoveReviewers(host, change, remove=None):
710 """Remove reveiewers from a change."""
711 if not remove:
712 return
713 if isinstance(remove, basestring):
714 remove = (remove,)
715 for r in remove:
716 path = 'changes/%s/reviewers/%s' % (change, r)
717 conn = CreateHttpConn(host, path, reqtype='DELETE')
718 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700719 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000720 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000721 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700722 e.http_status,
723 'Received unexpected http status while deleting reviewer "%s" '
724 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000725
726
Aaron Gable636b13f2017-07-14 10:42:48 -0700727def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000728 """Set labels and/or add a message to a code review."""
729 if not msg and not labels:
730 return
731 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800732 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000733 if msg:
734 body['message'] = msg
735 if labels:
736 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700737 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700738 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700739 if ready:
740 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000741 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
742 response = ReadHttpJsonResponse(conn)
743 if labels:
744 for key, val in labels.iteritems():
745 if ('labels' not in response or key not in response['labels'] or
746 int(response['labels'][key] != int(val))):
747 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
748 key, change))
749
750
751def ResetReviewLabels(host, change, label, value='0', message=None,
752 notify=None):
753 """Reset the value of a given label for all reviewers on a change."""
754 # This is tricky, because we want to work on the "current revision", but
755 # there's always the risk that "current revision" will change in between
756 # API calls. So, we check "current revision" at the beginning and end; if
757 # it has changed, raise an exception.
758 jmsg = GetChangeCurrentRevision(host, change)
759 if not jmsg:
760 raise GerritError(
761 200, 'Could not get review information for change "%s"' % change)
762 value = str(value)
763 revision = jmsg[0]['current_revision']
764 path = 'changes/%s/revisions/%s/review' % (change, revision)
765 message = message or (
766 '%s label set to %s programmatically.' % (label, value))
767 jmsg = GetReview(host, change, revision)
768 if not jmsg:
769 raise GerritError(200, 'Could not get review information for revison %s '
770 'of change %s' % (revision, change))
771 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
772 if str(review.get('value', value)) != value:
773 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800774 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000775 'message': message,
776 'labels': {label: value},
777 'on_behalf_of': review['_account_id'],
778 }
779 if notify:
780 body['notify'] = notify
781 conn = CreateHttpConn(
782 host, path, reqtype='POST', body=body)
783 response = ReadHttpJsonResponse(conn)
784 if str(response['labels'][label]) != value:
785 username = review.get('email', jmsg.get('name', ''))
786 raise GerritError(200, 'Unable to set %s label for user "%s"'
787 ' on change %s.' % (label, username, change))
788 jmsg = GetChangeCurrentRevision(host, change)
789 if not jmsg:
790 raise GerritError(
791 200, 'Could not get review information for change "%s"' % change)
792 elif jmsg[0]['current_revision'] != revision:
793 raise GerritError(200, 'While resetting labels on change "%s", '
794 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800795
796
dimu833c94c2017-01-18 17:36:15 -0800797def CreateGerritBranch(host, project, branch, commit):
798 """
799 Create a new branch from given project and commit
800 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
801
802 Returns:
803 A JSON with 'ref' key
804 """
805 path = 'projects/%s/branches/%s' % (project, branch)
806 body = {'revision': commit}
807 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700808 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800809 if response:
810 return response
811 raise GerritError(200, 'Unable to create gerrit branch')
812
813
814def GetGerritBranch(host, project, branch):
815 """
816 Get a branch from given project and commit
817 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
818
819 Returns:
820 A JSON object with 'revision' key
821 """
822 path = 'projects/%s/branches/%s' % (project, branch)
823 conn = CreateHttpConn(host, path, reqtype='GET')
824 response = ReadHttpJsonResponse(conn)
825 if response:
826 return response
827 raise GerritError(200, 'Unable to get gerrit branch')
828
829
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100830def GetAccountDetails(host, account_id='self'):
831 """Returns details of the account.
832
833 If account_id is not given, uses magic value 'self' which corresponds to
834 whichever account user is authenticating as.
835
836 Documentation:
837 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
838 """
839 if account_id != 'self':
840 account_id = int(account_id)
841 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
842 return ReadHttpJsonResponse(conn)
843
844
Nick Carter8692b182017-11-06 16:30:38 -0800845def PercentEncodeForGitRef(original):
846 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
847
848 The encoding used is based on but stricter than URL encoding (Section 2.1
849 of RFC 3986). The only non-escaped characters are alphanumerics, and
850 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
851 'PLUS SIGN' (U+002B).
852
853 For more information, see the Gerrit docs here:
854
855 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
856 """
857 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
858 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
859
860 # spaces are not allowed in git refs; gerrit will interpret either '_' or
861 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
862 return encoded.replace(' ', '_')
863
864
Dan Jacques8d11e482016-11-15 14:25:56 -0800865@contextlib.contextmanager
866def tempdir():
867 tdir = None
868 try:
869 tdir = tempfile.mkdtemp(suffix='gerrit_util')
870 yield tdir
871 finally:
872 if tdir:
873 gclient_utils.rmtree(tdir)