blob: fac982314c6600adb62df4ec0fd70004020480f3 [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
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010030from third_party import httplib2
szager@chromium.orgf202a252014-05-27 18:55:52 +000031
szager@chromium.orgb4696232013-10-16 19:45:35 +000032LOGGER = logging.getLogger()
Aaron Gable92e9f382017-12-07 11:47:41 -080033# With a starting sleep time of 1 second, 2^n exponential backoff, and six
34# total tries, the sleep time between the first and last tries will be 31s.
35TRY_LIMIT = 6
szager@chromium.orgb4696232013-10-16 19:45:35 +000036
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000037
szager@chromium.orgb4696232013-10-16 19:45:35 +000038# Controls the transport protocol used to communicate with gerrit.
39# This is parameterized primarily to enable GerritTestCase.
40GERRIT_PROTOCOL = 'https'
41
42
43class GerritError(Exception):
44 """Exception class for errors commuicating with the gerrit-on-borg service."""
45 def __init__(self, http_status, *args, **kwargs):
46 super(GerritError, self).__init__(*args, **kwargs)
47 self.http_status = http_status
48 self.message = '(%d) %s' % (self.http_status, self.message)
49
50
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000051class GerritAuthenticationError(GerritError):
52 """Exception class for authentication errors during Gerrit communication."""
53
54
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020055def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000056 """Encodes query parameters in the key:val[+key:val...] format specified here:
57
58 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
59 """
60 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020061 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000062 return '+'.join(q)
63
64
Aaron Gabled2db5a22017-03-24 14:14:15 -070065def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000066 if protocol is None:
67 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010068 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070069 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000070 else:
71 raise RuntimeError(
72 "Don't know how to work with protocol '%s'" % protocol)
73
74
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000075class Authenticator(object):
76 """Base authenticator class for authenticator implementations to subclass."""
77
78 def get_auth_header(self, host):
79 raise NotImplementedError()
80
81 @staticmethod
82 def get():
83 """Returns: (Authenticator) The identified Authenticator to use.
84
85 Probes the local system and its environment and identifies the
86 Authenticator instance to use.
87 """
88 if GceAuthenticator.is_gce():
89 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000090 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000091
92
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000093class CookiesAuthenticator(Authenticator):
94 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
95
96 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000097 """
98
99 def __init__(self):
100 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000101 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000102
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000103 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200104 def get_new_password_url(cls, host):
105 assert not host.startswith('http')
106 # Assume *.googlesource.com pattern.
107 parts = host.split('.')
108 if not parts[0].endswith('-review'):
109 parts[0] += '-review'
110 return 'https://%s/new-password' % ('.'.join(parts))
111
112 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000113 def get_new_password_message(cls, host):
114 assert not host.startswith('http')
115 # Assume *.googlesource.com pattern.
116 parts = host.split('.')
117 if not parts[0].endswith('-review'):
118 parts[0] += '-review'
119 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100120 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000121
122 @classmethod
123 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000124 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000125 return os.path.expanduser(os.path.join('~', path))
126
127 @classmethod
128 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800129 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000130 path = cls.get_netrc_path()
Dan Jacques8d11e482016-11-15 14:25:56 -0800131 content = ''
132 if os.path.exists(path):
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000133 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000134 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
135 print >> sys.stderr, (
136 'WARNING: netrc file %s cannot be used because its file '
137 'permissions are insecure. netrc file permissions should be '
138 '600.' % path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800139 with open(path) as fd:
140 content = fd.read()
141
142 # Load the '.netrc' file. We strip comments from it because processing them
143 # can trigger a bug in Windows. See crbug.com/664664.
144 content = '\n'.join(l for l in content.splitlines()
145 if l.strip() and not l.strip().startswith('#'))
146 with tempdir() as tdir:
147 netrc_path = os.path.join(tdir, 'netrc')
148 with open(netrc_path, 'w') as fd:
149 fd.write(content)
150 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
151 return cls._get_netrc_from_path(netrc_path)
152
153 @classmethod
154 def _get_netrc_from_path(cls, path):
155 try:
156 return netrc.netrc(path)
157 except IOError:
158 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
159 return netrc.netrc(os.devnull)
160 except netrc.NetrcParseError as e:
161 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
162 'parsing error: %s' % (path, e))
163 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000164
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000165 @classmethod
166 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500167 if os.getenv('GIT_COOKIES_PATH'):
168 return os.getenv('GIT_COOKIES_PATH')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000169 return os.path.join(os.environ['HOME'], '.gitcookies')
170
171 @classmethod
172 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000173 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000174 path = cls.get_gitcookies_path()
175 if not os.path.exists(path):
176 return gitcookies
177
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000178 try:
179 f = open(path, 'rb')
180 except IOError:
181 return gitcookies
182
183 with f:
184 for line in f:
185 try:
186 fields = line.strip().split('\t')
187 if line.strip().startswith('#') or len(fields) != 7:
188 continue
189 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
190 if xpath == '/' and key == 'o':
191 login, secret_token = value.split('=', 1)
192 gitcookies[domain] = (login, secret_token)
193 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100194 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000195
196 return gitcookies
197
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100198 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000199 for domain, creds in self.gitcookies.iteritems():
200 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100201 return (creds[0], None, creds[1])
202 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000203
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100204 def get_auth_header(self, host):
205 auth = self._get_auth_for_host(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000206 if auth:
207 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
208 return None
209
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100210 def get_auth_email(self, host):
211 """Best effort parsing of email to be used for auth for the given host."""
212 auth = self._get_auth_for_host(host)
213 if not auth:
214 return None
215 login = auth[0]
216 # login typically looks like 'git-xxx.example.com'
217 if not login.startswith('git-') or '.' not in login:
218 return None
219 username, domain = login[len('git-'):].split('.', 1)
220 return '%s@%s' % (username, domain)
221
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100222
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000223# Backwards compatibility just in case somebody imports this outside of
224# depot_tools.
225NetrcAuthenticator = CookiesAuthenticator
226
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000227
228class GceAuthenticator(Authenticator):
229 """Authenticator implementation that uses GCE metadata service for token.
230 """
231
232 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700233 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
234 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000235 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
236
237 _cache_is_gce = None
238 _token_cache = None
239 _token_expiration = None
240
241 @classmethod
242 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500243 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
244 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000245 if cls._cache_is_gce is None:
246 cls._cache_is_gce = cls._test_is_gce()
247 return cls._cache_is_gce
248
249 @classmethod
250 def _test_is_gce(cls):
251 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
252 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100253 resp, _ = cls._get(cls._INFO_URL)
254 except (socket.error, httplib2.ServerNotFoundError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000255 # Could not resolve URL.
256 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100257 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000258
259 @staticmethod
260 def _get(url, **kwargs):
261 next_delay_sec = 1
262 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000263 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700264 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100265 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000266 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
267 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100268 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000269
Aaron Gable92e9f382017-12-07 11:47:41 -0800270 # Retry server error status codes.
271 LOGGER.warn('Encountered server error')
272 if TRY_LIMIT - i > 1:
273 LOGGER.info('Will retry in %d seconds (%d more times)...',
274 next_delay_sec, TRY_LIMIT - i - 1)
275 time.sleep(next_delay_sec)
276 next_delay_sec *= 2
277
278
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000279 @classmethod
280 def _get_token_dict(cls):
281 if cls._token_cache:
282 # If it expires within 25 seconds, refresh.
283 if cls._token_expiration < time.time() - 25:
284 return cls._token_cache
285
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100286 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000287 if resp.status != httplib.OK:
288 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100289 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000290 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
291 return cls._token_cache
292
293 def get_auth_header(self, _host):
294 token_dict = self._get_token_dict()
295 if not token_dict:
296 return None
297 return '%(token_type)s %(access_token)s' % token_dict
298
299
szager@chromium.orgb4696232013-10-16 19:45:35 +0000300def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
301 """Opens an https connection to a gerrit service, and sends a request."""
302 headers = headers or {}
303 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000304
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000305 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000306 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000307 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000308 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000309 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000310
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800311 url = path
312 if not url.startswith('/'):
313 url = '/' + url
314 if 'Authorization' in headers and not url.startswith('/a/'):
315 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000316
szager@chromium.orgb4696232013-10-16 19:45:35 +0000317 if body:
318 body = json.JSONEncoder().encode(body)
319 headers.setdefault('Content-Type', 'application/json')
320 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000321 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000322 for key, val in headers.iteritems():
323 if key == 'Authorization':
324 val = 'HIDDEN'
325 LOGGER.debug('%s: %s' % (key, val))
326 if body:
327 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700328 conn = GetConnectionObject()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000329 conn.req_host = host
330 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100331 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000332 'method': reqtype,
333 'headers': headers,
334 'body': body,
335 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000336 return conn
337
338
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700339def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000340 """Reads an http response from a connection into a string buffer.
341
342 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100343 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700344 accept_statuses: Treat any of these statuses as success. Default: [200]
345 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000346 Returns: A string buffer containing the connection's reply.
347 """
Aaron Gable92e9f382017-12-07 11:47:41 -0800348 sleep_time = 1
szager@chromium.orgb4696232013-10-16 19:45:35 +0000349 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100350 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000351
352 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100353 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000354 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
355 www_authenticate):
356 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
357 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700358 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000359 'has credentials for %s' % host)
360 raise GerritAuthenticationError(response.status, reason)
361
szager@chromium.orgb4696232013-10-16 19:45:35 +0000362 # If response.status < 500 then the result is final; break retry loop.
Aaron Gable62ca9602017-05-19 17:24:52 -0700363 # If the response is 404, it might be because of replication lag, so
364 # keep trying anyway.
Michael Mossb40a4512017-10-10 11:07:17 -0700365 if ((response.status < 500 and response.status != 404)
366 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100367 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100368 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700369 # If 404 was in accept_statuses, then it's expected that the file might
370 # not exist, so don't return the gitiles error page because that's not the
371 # "content" that was actually requested.
372 if response.status == 404:
373 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000374 break
375 # A status >=500 is assumed to be a possible transient error; retry.
376 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100377 LOGGER.warn('A transient error occurred while querying %s:\n'
378 '%s %s %s\n'
379 '%s %d %s',
Aaron Gable20d2cbb2017-04-25 15:04:05 -0700380 conn.req_host, conn.req_params['method'],
381 conn.req_params['uri'],
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100382 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000383 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800384 LOGGER.info('Will retry in %d seconds (%d more times)...',
385 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000386 time.sleep(sleep_time)
387 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700388 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200389 if response.status in (401, 403):
390 print('Your Gerrit credentials might be misconfigured. Try: \n'
391 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100392 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000393 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100394 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000395
396
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700397def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000398 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700399 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000400 # The first line of the response should always be: )]}'
401 s = fh.readline()
402 if s and s.rstrip() != ")]}'":
403 raise GerritError(200, 'Unexpected json output: %s' % s)
404 s = fh.read()
405 if not s:
406 return None
407 return json.loads(s)
408
409
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200410def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100411 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000412 """
413 Queries a gerrit-on-borg server for changes matching query terms.
414
415 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200416 params: A list of key:value pairs for search parameters, as documented
417 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
418 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000419 first_param: A change identifier
420 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100421 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000422 o_params: A list of additional output specifiers, as documented here:
423 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
424 Returns:
425 A list of json-decoded query results.
426 """
427 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200428 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000429 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200430 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100431 if start:
432 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000433 if limit:
434 path = '%s&n=%d' % (path, limit)
435 if o_params:
436 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700437 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000438
439
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200440def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100441 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000442 """
443 Queries a gerrit-on-borg server for all the changes matching the query terms.
444
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100445 WARNING: this is unreliable if a change matching the query is modified while
446 this function is being called.
447
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000448 A single query to gerrit-on-borg is limited on the number of results by the
449 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100450 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000451
452 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200453 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000454 limit: Maximum number of requested changes per query.
455 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100456 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000457
458 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100459 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000460 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100461 already_returned = set()
462 def at_most_once(cls):
463 for cl in cls:
464 if cl['_number'] not in already_returned:
465 already_returned.add(cl['_number'])
466 yield cl
467
468 start = start or 0
469 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000470 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100471
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000472 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100473 # This will fetch changes[start..start+limit] sorted by most recently
474 # updated. Since the rank of any change in this list can be changed any time
475 # (say user posting comment), subsequent calls may overalp like this:
476 # > initial order ABCDEFGH
477 # query[0..3] => ABC
478 # > E get's updated. New order: EABCDFGH
479 # query[3..6] => CDF # C is a dup
480 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200481 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100482 cur_start)
483 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000484 yield cl
485
486 more_changes = [cl for cl in page if '_more_changes' in cl]
487 if len(more_changes) > 1:
488 raise GerritError(
489 200,
490 'Received %d changes with a _more_changes attribute set but should '
491 'receive at most one.' % len(more_changes))
492 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100493 cur_start += len(page)
494
495 # If we paged through, query again the first page which in most circumstances
496 # will fetch all changes that were modified while this function was run.
497 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200498 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 for cl in at_most_once(page):
500 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000501
502
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200503def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100504 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000505 """Initiate a query composed of multiple sets of query parameters."""
506 if not change_list:
507 raise RuntimeError(
508 "MultiQueryChanges requires a list of change numbers/id's")
509 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200510 if params:
511 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000512 if limit:
513 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100514 if start:
515 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000516 if o_params:
517 q.extend(['o=%s' % p for p in o_params])
518 path = 'changes/?%s' % '&'.join(q)
519 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700520 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000521 except GerritError as e:
522 msg = '%s:\n%s' % (e.message, path)
523 raise GerritError(e.http_status, msg)
524 return result
525
526
527def GetGerritFetchUrl(host):
528 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
529 return '%s://%s/' % (GERRIT_PROTOCOL, host)
530
531
532def GetChangePageUrl(host, change_number):
533 """Given a gerrit host name and change number, return change page url."""
534 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
535
536
537def GetChangeUrl(host, change):
538 """Given a gerrit host name and change id, return an url for the change."""
539 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
540
541
542def GetChange(host, change):
543 """Query a gerrit server for information about a single change."""
544 path = 'changes/%s' % change
545 return ReadHttpJsonResponse(CreateHttpConn(host, path))
546
547
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700548def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000549 """Query a gerrit server for extended information about a single change."""
550 path = 'changes/%s/detail' % change
551 if o_params:
552 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700553 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000554
555
agable32978d92016-11-01 12:55:02 -0700556def GetChangeCommit(host, change, revision='current'):
557 """Query a gerrit server for a revision associated with a change."""
558 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
559 return ReadHttpJsonResponse(CreateHttpConn(host, path))
560
561
szager@chromium.orgb4696232013-10-16 19:45:35 +0000562def GetChangeCurrentRevision(host, change):
563 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200564 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000565
566
567def GetChangeRevisions(host, change):
568 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200569 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000570
571
572def GetChangeReview(host, change, revision=None):
573 """Get the current review information for a change."""
574 if not revision:
575 jmsg = GetChangeRevisions(host, change)
576 if not jmsg:
577 return None
578 elif len(jmsg) > 1:
579 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
580 revision = jmsg[0]['current_revision']
581 path = 'changes/%s/revisions/%s/review'
582 return ReadHttpJsonResponse(CreateHttpConn(host, path))
583
584
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700585def GetChangeComments(host, change):
586 """Get the line- and file-level comments on a change."""
587 path = 'changes/%s/comments' % change
588 return ReadHttpJsonResponse(CreateHttpConn(host, path))
589
590
szager@chromium.orgb4696232013-10-16 19:45:35 +0000591def AbandonChange(host, change, msg=''):
592 """Abandon a gerrit change."""
593 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000594 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000595 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700596 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000597
598
599def RestoreChange(host, change, msg=''):
600 """Restore a previously abandoned change."""
601 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000602 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000603 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700604 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000605
606
607def SubmitChange(host, change, wait_for_merge=True):
608 """Submits a gerrit change via Gerrit."""
609 path = 'changes/%s/submit' % change
610 body = {'wait_for_merge': wait_for_merge}
611 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700612 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000613
614
dsansomee2d6fd92016-09-08 00:10:47 -0700615def HasPendingChangeEdit(host, change):
616 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
617 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700618 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700619 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700620 # 204 No Content means no pending change.
621 if e.http_status == 204:
622 return False
623 raise
624 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700625
626
627def DeletePendingChangeEdit(host, change):
628 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700629 # On success, gerrit returns status 204; if the edit was already deleted it
630 # returns 404. Anything else is an error.
631 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700632
633
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100634def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000635 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700636 assert notify in ('ALL', 'NONE')
637 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700638 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700639 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000640 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700641 ReadHttpResponse(conn, accept_statuses=[200, 204])
642 except GerritError as e:
643 raise GerritError(
644 e.http_status,
645 'Received unexpected http status while editing message '
646 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000647
648
szager@chromium.orgb4696232013-10-16 19:45:35 +0000649def GetReviewers(host, change):
650 """Get information about all reviewers attached to a change."""
651 path = 'changes/%s/reviewers' % change
652 return ReadHttpJsonResponse(CreateHttpConn(host, path))
653
654
655def GetReview(host, change, revision):
656 """Get review information about a specific revision of a change."""
657 path = 'changes/%s/revisions/%s/review' % (change, revision)
658 return ReadHttpJsonResponse(CreateHttpConn(host, path))
659
660
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700661def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
662 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000663 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700664 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800665 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200666 if not change:
667 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700668 reviewers = frozenset(reviewers or [])
669 ccs = frozenset(ccs or [])
670 path = 'changes/%s/revisions/current/review' % change
671
672 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800673 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700674 'reviewers': [],
675 'notify': 'ALL' if notify else 'NONE',
676 }
677 for r in sorted(reviewers | ccs):
678 state = 'REVIEWER' if r in reviewers else 'CC'
679 body['reviewers'].append({
680 'reviewer': r,
681 'state': state,
682 'notify': 'NONE', # We handled `notify` argument above.
683 })
684
685 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
686 # Gerrit will return 400 if one or more of the requested reviewers are
687 # unprocessable. We read the response object to see which were rejected,
688 # warn about them, and retry with the remainder.
689 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
690
691 errored = set()
692 for result in resp.get('reviewers', {}).itervalues():
693 r = result.get('input')
694 state = 'REVIEWER' if r in reviewers else 'CC'
695 if result.get('error'):
696 errored.add(r)
697 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
698 if errored:
699 # Try again, adding only those that didn't fail, and only accepting 200.
700 AddReviewers(host, change, reviewers=(reviewers-errored),
701 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000702
703
704def RemoveReviewers(host, change, remove=None):
705 """Remove reveiewers from a change."""
706 if not remove:
707 return
708 if isinstance(remove, basestring):
709 remove = (remove,)
710 for r in remove:
711 path = 'changes/%s/reviewers/%s' % (change, r)
712 conn = CreateHttpConn(host, path, reqtype='DELETE')
713 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700714 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000715 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000716 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700717 e.http_status,
718 'Received unexpected http status while deleting reviewer "%s" '
719 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000720
721
Aaron Gable636b13f2017-07-14 10:42:48 -0700722def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000723 """Set labels and/or add a message to a code review."""
724 if not msg and not labels:
725 return
726 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800727 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000728 if msg:
729 body['message'] = msg
730 if labels:
731 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700732 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700733 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700734 if ready:
735 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000736 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
737 response = ReadHttpJsonResponse(conn)
738 if labels:
739 for key, val in labels.iteritems():
740 if ('labels' not in response or key not in response['labels'] or
741 int(response['labels'][key] != int(val))):
742 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
743 key, change))
744
745
746def ResetReviewLabels(host, change, label, value='0', message=None,
747 notify=None):
748 """Reset the value of a given label for all reviewers on a change."""
749 # This is tricky, because we want to work on the "current revision", but
750 # there's always the risk that "current revision" will change in between
751 # API calls. So, we check "current revision" at the beginning and end; if
752 # it has changed, raise an exception.
753 jmsg = GetChangeCurrentRevision(host, change)
754 if not jmsg:
755 raise GerritError(
756 200, 'Could not get review information for change "%s"' % change)
757 value = str(value)
758 revision = jmsg[0]['current_revision']
759 path = 'changes/%s/revisions/%s/review' % (change, revision)
760 message = message or (
761 '%s label set to %s programmatically.' % (label, value))
762 jmsg = GetReview(host, change, revision)
763 if not jmsg:
764 raise GerritError(200, 'Could not get review information for revison %s '
765 'of change %s' % (revision, change))
766 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
767 if str(review.get('value', value)) != value:
768 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800769 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000770 'message': message,
771 'labels': {label: value},
772 'on_behalf_of': review['_account_id'],
773 }
774 if notify:
775 body['notify'] = notify
776 conn = CreateHttpConn(
777 host, path, reqtype='POST', body=body)
778 response = ReadHttpJsonResponse(conn)
779 if str(response['labels'][label]) != value:
780 username = review.get('email', jmsg.get('name', ''))
781 raise GerritError(200, 'Unable to set %s label for user "%s"'
782 ' on change %s.' % (label, username, change))
783 jmsg = GetChangeCurrentRevision(host, change)
784 if not jmsg:
785 raise GerritError(
786 200, 'Could not get review information for change "%s"' % change)
787 elif jmsg[0]['current_revision'] != revision:
788 raise GerritError(200, 'While resetting labels on change "%s", '
789 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800790
791
dimu833c94c2017-01-18 17:36:15 -0800792def CreateGerritBranch(host, project, branch, commit):
793 """
794 Create a new branch from given project and commit
795 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
796
797 Returns:
798 A JSON with 'ref' key
799 """
800 path = 'projects/%s/branches/%s' % (project, branch)
801 body = {'revision': commit}
802 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700803 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800804 if response:
805 return response
806 raise GerritError(200, 'Unable to create gerrit branch')
807
808
809def GetGerritBranch(host, project, branch):
810 """
811 Get a branch from given project and commit
812 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
813
814 Returns:
815 A JSON object with 'revision' key
816 """
817 path = 'projects/%s/branches/%s' % (project, branch)
818 conn = CreateHttpConn(host, path, reqtype='GET')
819 response = ReadHttpJsonResponse(conn)
820 if response:
821 return response
822 raise GerritError(200, 'Unable to get gerrit branch')
823
824
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100825def GetAccountDetails(host, account_id='self'):
826 """Returns details of the account.
827
828 If account_id is not given, uses magic value 'self' which corresponds to
829 whichever account user is authenticating as.
830
831 Documentation:
832 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
833 """
834 if account_id != 'self':
835 account_id = int(account_id)
836 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
837 return ReadHttpJsonResponse(conn)
838
839
Nick Carter8692b182017-11-06 16:30:38 -0800840def PercentEncodeForGitRef(original):
841 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
842
843 The encoding used is based on but stricter than URL encoding (Section 2.1
844 of RFC 3986). The only non-escaped characters are alphanumerics, and
845 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
846 'PLUS SIGN' (U+002B).
847
848 For more information, see the Gerrit docs here:
849
850 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
851 """
852 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
853 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
854
855 # spaces are not allowed in git refs; gerrit will interpret either '_' or
856 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
857 return encoded.replace(' ', '_')
858
859
Dan Jacques8d11e482016-11-15 14:25:56 -0800860@contextlib.contextmanager
861def tempdir():
862 tdir = None
863 try:
864 tdir = tempfile.mkdtemp(suffix='gerrit_util')
865 yield tdir
866 finally:
867 if tdir:
868 gclient_utils.rmtree(tdir)