blob: 14455645ccbeebf557d71b3b0a7c1672886d1eca [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)
Sergio Villar Senin0d466d22018-03-23 14:31:48 +0100259 except (socket.error, httplib2.ServerNotFoundError,
260 httplib2.socks.HTTPError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000261 # Could not resolve URL.
262 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100263 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000264
265 @staticmethod
266 def _get(url, **kwargs):
267 next_delay_sec = 1
268 for i in xrange(TRY_LIMIT):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000269 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700270 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100271 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000272 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
273 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100274 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000275
Aaron Gable92e9f382017-12-07 11:47:41 -0800276 # Retry server error status codes.
277 LOGGER.warn('Encountered server error')
278 if TRY_LIMIT - i > 1:
279 LOGGER.info('Will retry in %d seconds (%d more times)...',
280 next_delay_sec, TRY_LIMIT - i - 1)
281 time.sleep(next_delay_sec)
282 next_delay_sec *= 2
283
284
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000285 @classmethod
286 def _get_token_dict(cls):
287 if cls._token_cache:
288 # If it expires within 25 seconds, refresh.
289 if cls._token_expiration < time.time() - 25:
290 return cls._token_cache
291
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100292 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000293 if resp.status != httplib.OK:
294 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100295 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000296 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
297 return cls._token_cache
298
299 def get_auth_header(self, _host):
300 token_dict = self._get_token_dict()
301 if not token_dict:
302 return None
303 return '%(token_type)s %(access_token)s' % token_dict
304
305
szager@chromium.orgb4696232013-10-16 19:45:35 +0000306def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
307 """Opens an https connection to a gerrit service, and sends a request."""
308 headers = headers or {}
309 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000310
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000311 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000312 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000313 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000314 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000315 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000316
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800317 url = path
318 if not url.startswith('/'):
319 url = '/' + url
320 if 'Authorization' in headers and not url.startswith('/a/'):
321 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000322
szager@chromium.orgb4696232013-10-16 19:45:35 +0000323 if body:
324 body = json.JSONEncoder().encode(body)
325 headers.setdefault('Content-Type', 'application/json')
326 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000327 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000328 for key, val in headers.iteritems():
329 if key == 'Authorization':
330 val = 'HIDDEN'
331 LOGGER.debug('%s: %s' % (key, val))
332 if body:
333 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700334 conn = GetConnectionObject()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000335 conn.req_host = host
336 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100337 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000338 'method': reqtype,
339 'headers': headers,
340 'body': body,
341 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000342 return conn
343
344
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700345def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000346 """Reads an http response from a connection into a string buffer.
347
348 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100349 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700350 accept_statuses: Treat any of these statuses as success. Default: [200]
351 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000352 Returns: A string buffer containing the connection's reply.
353 """
Aaron Gable92e9f382017-12-07 11:47:41 -0800354 sleep_time = 1
szager@chromium.orgb4696232013-10-16 19:45:35 +0000355 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100356 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000357
358 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100359 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000360 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
361 www_authenticate):
362 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
363 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700364 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000365 'has credentials for %s' % host)
366 raise GerritAuthenticationError(response.status, reason)
367
szager@chromium.orgb4696232013-10-16 19:45:35 +0000368 # If response.status < 500 then the result is final; break retry loop.
Aaron Gable62ca9602017-05-19 17:24:52 -0700369 # If the response is 404, it might be because of replication lag, so
370 # keep trying anyway.
Michael Mossb40a4512017-10-10 11:07:17 -0700371 if ((response.status < 500 and response.status != 404)
372 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100373 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100374 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700375 # If 404 was in accept_statuses, then it's expected that the file might
376 # not exist, so don't return the gitiles error page because that's not the
377 # "content" that was actually requested.
378 if response.status == 404:
379 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000380 break
381 # A status >=500 is assumed to be a possible transient error; retry.
382 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100383 LOGGER.warn('A transient error occurred while querying %s:\n'
384 '%s %s %s\n'
385 '%s %d %s',
Aaron Gable20d2cbb2017-04-25 15:04:05 -0700386 conn.req_host, conn.req_params['method'],
387 conn.req_params['uri'],
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100388 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000389 if TRY_LIMIT - idx > 1:
Aaron Gable92e9f382017-12-07 11:47:41 -0800390 LOGGER.info('Will retry in %d seconds (%d more times)...',
391 sleep_time, TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000392 time.sleep(sleep_time)
393 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700394 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200395 if response.status in (401, 403):
396 print('Your Gerrit credentials might be misconfigured. Try: \n'
397 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100398 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000399 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100400 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000401
402
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700403def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000404 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700405 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000406 # The first line of the response should always be: )]}'
407 s = fh.readline()
408 if s and s.rstrip() != ")]}'":
409 raise GerritError(200, 'Unexpected json output: %s' % s)
410 s = fh.read()
411 if not s:
412 return None
413 return json.loads(s)
414
415
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200416def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100417 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000418 """
419 Queries a gerrit-on-borg server for changes matching query terms.
420
421 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200422 params: A list of key:value pairs for search parameters, as documented
423 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
424 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000425 first_param: A change identifier
426 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100427 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000428 o_params: A list of additional output specifiers, as documented here:
429 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
430 Returns:
431 A list of json-decoded query results.
432 """
433 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200434 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000435 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200436 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100437 if start:
438 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000439 if limit:
440 path = '%s&n=%d' % (path, limit)
441 if o_params:
442 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700443 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000444
445
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200446def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100447 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000448 """
449 Queries a gerrit-on-borg server for all the changes matching the query terms.
450
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100451 WARNING: this is unreliable if a change matching the query is modified while
452 this function is being called.
453
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000454 A single query to gerrit-on-borg is limited on the number of results by the
455 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100456 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000457
458 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200459 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000460 limit: Maximum number of requested changes per query.
461 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100462 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000463
464 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100465 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000466 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100467 already_returned = set()
468 def at_most_once(cls):
469 for cl in cls:
470 if cl['_number'] not in already_returned:
471 already_returned.add(cl['_number'])
472 yield cl
473
474 start = start or 0
475 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000476 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100477
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000478 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100479 # This will fetch changes[start..start+limit] sorted by most recently
480 # updated. Since the rank of any change in this list can be changed any time
481 # (say user posting comment), subsequent calls may overalp like this:
482 # > initial order ABCDEFGH
483 # query[0..3] => ABC
484 # > E get's updated. New order: EABCDFGH
485 # query[3..6] => CDF # C is a dup
486 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200487 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100488 cur_start)
489 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000490 yield cl
491
492 more_changes = [cl for cl in page if '_more_changes' in cl]
493 if len(more_changes) > 1:
494 raise GerritError(
495 200,
496 'Received %d changes with a _more_changes attribute set but should '
497 'receive at most one.' % len(more_changes))
498 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 cur_start += len(page)
500
501 # If we paged through, query again the first page which in most circumstances
502 # will fetch all changes that were modified while this function was run.
503 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200504 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100505 for cl in at_most_once(page):
506 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000507
508
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200509def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100510 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000511 """Initiate a query composed of multiple sets of query parameters."""
512 if not change_list:
513 raise RuntimeError(
514 "MultiQueryChanges requires a list of change numbers/id's")
515 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200516 if params:
517 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000518 if limit:
519 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100520 if start:
521 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000522 if o_params:
523 q.extend(['o=%s' % p for p in o_params])
524 path = 'changes/?%s' % '&'.join(q)
525 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700526 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000527 except GerritError as e:
528 msg = '%s:\n%s' % (e.message, path)
529 raise GerritError(e.http_status, msg)
530 return result
531
532
533def GetGerritFetchUrl(host):
534 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
535 return '%s://%s/' % (GERRIT_PROTOCOL, host)
536
537
538def GetChangePageUrl(host, change_number):
539 """Given a gerrit host name and change number, return change page url."""
540 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
541
542
543def GetChangeUrl(host, change):
544 """Given a gerrit host name and change id, return an url for the change."""
545 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
546
547
548def GetChange(host, change):
549 """Query a gerrit server for information about a single change."""
550 path = 'changes/%s' % change
551 return ReadHttpJsonResponse(CreateHttpConn(host, path))
552
553
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700554def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000555 """Query a gerrit server for extended information about a single change."""
556 path = 'changes/%s/detail' % change
557 if o_params:
558 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700559 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000560
561
agable32978d92016-11-01 12:55:02 -0700562def GetChangeCommit(host, change, revision='current'):
563 """Query a gerrit server for a revision associated with a change."""
564 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
565 return ReadHttpJsonResponse(CreateHttpConn(host, path))
566
567
szager@chromium.orgb4696232013-10-16 19:45:35 +0000568def GetChangeCurrentRevision(host, change):
569 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200570 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000571
572
573def GetChangeRevisions(host, change):
574 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200575 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000576
577
578def GetChangeReview(host, change, revision=None):
579 """Get the current review information for a change."""
580 if not revision:
581 jmsg = GetChangeRevisions(host, change)
582 if not jmsg:
583 return None
584 elif len(jmsg) > 1:
585 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
586 revision = jmsg[0]['current_revision']
587 path = 'changes/%s/revisions/%s/review'
588 return ReadHttpJsonResponse(CreateHttpConn(host, path))
589
590
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700591def GetChangeComments(host, change):
592 """Get the line- and file-level comments on a change."""
593 path = 'changes/%s/comments' % change
594 return ReadHttpJsonResponse(CreateHttpConn(host, path))
595
596
szager@chromium.orgb4696232013-10-16 19:45:35 +0000597def AbandonChange(host, change, msg=''):
598 """Abandon a gerrit change."""
599 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000600 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000601 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700602 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000603
604
605def RestoreChange(host, change, msg=''):
606 """Restore a previously abandoned change."""
607 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000608 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000609 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700610 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000611
612
613def SubmitChange(host, change, wait_for_merge=True):
614 """Submits a gerrit change via Gerrit."""
615 path = 'changes/%s/submit' % change
616 body = {'wait_for_merge': wait_for_merge}
617 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700618 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000619
620
dsansomee2d6fd92016-09-08 00:10:47 -0700621def HasPendingChangeEdit(host, change):
622 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
623 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700624 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700625 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700626 # 204 No Content means no pending change.
627 if e.http_status == 204:
628 return False
629 raise
630 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700631
632
633def DeletePendingChangeEdit(host, change):
634 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700635 # On success, gerrit returns status 204; if the edit was already deleted it
636 # returns 404. Anything else is an error.
637 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700638
639
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100640def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000641 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700642 assert notify in ('ALL', 'NONE')
643 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700644 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700645 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000646 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700647 ReadHttpResponse(conn, accept_statuses=[200, 204])
648 except GerritError as e:
649 raise GerritError(
650 e.http_status,
651 'Received unexpected http status while editing message '
652 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000653
654
szager@chromium.orgb4696232013-10-16 19:45:35 +0000655def GetReviewers(host, change):
656 """Get information about all reviewers attached to a change."""
657 path = 'changes/%s/reviewers' % change
658 return ReadHttpJsonResponse(CreateHttpConn(host, path))
659
660
661def GetReview(host, change, revision):
662 """Get review information about a specific revision of a change."""
663 path = 'changes/%s/revisions/%s/review' % (change, revision)
664 return ReadHttpJsonResponse(CreateHttpConn(host, path))
665
666
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700667def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
668 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000669 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700670 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800671 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200672 if not change:
673 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700674 reviewers = frozenset(reviewers or [])
675 ccs = frozenset(ccs or [])
676 path = 'changes/%s/revisions/current/review' % change
677
678 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800679 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700680 'reviewers': [],
681 'notify': 'ALL' if notify else 'NONE',
682 }
683 for r in sorted(reviewers | ccs):
684 state = 'REVIEWER' if r in reviewers else 'CC'
685 body['reviewers'].append({
686 'reviewer': r,
687 'state': state,
688 'notify': 'NONE', # We handled `notify` argument above.
689 })
690
691 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
692 # Gerrit will return 400 if one or more of the requested reviewers are
693 # unprocessable. We read the response object to see which were rejected,
694 # warn about them, and retry with the remainder.
695 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
696
697 errored = set()
698 for result in resp.get('reviewers', {}).itervalues():
699 r = result.get('input')
700 state = 'REVIEWER' if r in reviewers else 'CC'
701 if result.get('error'):
702 errored.add(r)
703 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
704 if errored:
705 # Try again, adding only those that didn't fail, and only accepting 200.
706 AddReviewers(host, change, reviewers=(reviewers-errored),
707 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000708
709
710def RemoveReviewers(host, change, remove=None):
711 """Remove reveiewers from a change."""
712 if not remove:
713 return
714 if isinstance(remove, basestring):
715 remove = (remove,)
716 for r in remove:
717 path = 'changes/%s/reviewers/%s' % (change, r)
718 conn = CreateHttpConn(host, path, reqtype='DELETE')
719 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700720 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000721 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000722 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700723 e.http_status,
724 'Received unexpected http status while deleting reviewer "%s" '
725 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000726
727
Aaron Gable636b13f2017-07-14 10:42:48 -0700728def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000729 """Set labels and/or add a message to a code review."""
730 if not msg and not labels:
731 return
732 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800733 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000734 if msg:
735 body['message'] = msg
736 if labels:
737 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700738 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700739 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700740 if ready:
741 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000742 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
743 response = ReadHttpJsonResponse(conn)
744 if labels:
745 for key, val in labels.iteritems():
746 if ('labels' not in response or key not in response['labels'] or
747 int(response['labels'][key] != int(val))):
748 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
749 key, change))
750
751
752def ResetReviewLabels(host, change, label, value='0', message=None,
753 notify=None):
754 """Reset the value of a given label for all reviewers on a change."""
755 # This is tricky, because we want to work on the "current revision", but
756 # there's always the risk that "current revision" will change in between
757 # API calls. So, we check "current revision" at the beginning and end; if
758 # it has changed, raise an exception.
759 jmsg = GetChangeCurrentRevision(host, change)
760 if not jmsg:
761 raise GerritError(
762 200, 'Could not get review information for change "%s"' % change)
763 value = str(value)
764 revision = jmsg[0]['current_revision']
765 path = 'changes/%s/revisions/%s/review' % (change, revision)
766 message = message or (
767 '%s label set to %s programmatically.' % (label, value))
768 jmsg = GetReview(host, change, revision)
769 if not jmsg:
770 raise GerritError(200, 'Could not get review information for revison %s '
771 'of change %s' % (revision, change))
772 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
773 if str(review.get('value', value)) != value:
774 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800775 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000776 'message': message,
777 'labels': {label: value},
778 'on_behalf_of': review['_account_id'],
779 }
780 if notify:
781 body['notify'] = notify
782 conn = CreateHttpConn(
783 host, path, reqtype='POST', body=body)
784 response = ReadHttpJsonResponse(conn)
785 if str(response['labels'][label]) != value:
786 username = review.get('email', jmsg.get('name', ''))
787 raise GerritError(200, 'Unable to set %s label for user "%s"'
788 ' on change %s.' % (label, username, change))
789 jmsg = GetChangeCurrentRevision(host, change)
790 if not jmsg:
791 raise GerritError(
792 200, 'Could not get review information for change "%s"' % change)
793 elif jmsg[0]['current_revision'] != revision:
794 raise GerritError(200, 'While resetting labels on change "%s", '
795 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800796
797
dimu833c94c2017-01-18 17:36:15 -0800798def CreateGerritBranch(host, project, branch, commit):
799 """
800 Create a new branch from given project and commit
801 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
802
803 Returns:
804 A JSON with 'ref' key
805 """
806 path = 'projects/%s/branches/%s' % (project, branch)
807 body = {'revision': commit}
808 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700809 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800810 if response:
811 return response
812 raise GerritError(200, 'Unable to create gerrit branch')
813
814
815def GetGerritBranch(host, project, branch):
816 """
817 Get a branch from given project and commit
818 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
819
820 Returns:
821 A JSON object with 'revision' key
822 """
823 path = 'projects/%s/branches/%s' % (project, branch)
824 conn = CreateHttpConn(host, path, reqtype='GET')
825 response = ReadHttpJsonResponse(conn)
826 if response:
827 return response
828 raise GerritError(200, 'Unable to get gerrit branch')
829
830
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100831def GetAccountDetails(host, account_id='self'):
832 """Returns details of the account.
833
834 If account_id is not given, uses magic value 'self' which corresponds to
835 whichever account user is authenticating as.
836
837 Documentation:
838 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
839 """
840 if account_id != 'self':
841 account_id = int(account_id)
842 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
843 return ReadHttpJsonResponse(conn)
844
845
Nick Carter8692b182017-11-06 16:30:38 -0800846def PercentEncodeForGitRef(original):
847 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
848
849 The encoding used is based on but stricter than URL encoding (Section 2.1
850 of RFC 3986). The only non-escaped characters are alphanumerics, and
851 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
852 'PLUS SIGN' (U+002B).
853
854 For more information, see the Gerrit docs here:
855
856 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
857 """
858 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
859 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
860
861 # spaces are not allowed in git refs; gerrit will interpret either '_' or
862 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
863 return encoded.replace(' ', '_')
864
865
Dan Jacques8d11e482016-11-15 14:25:56 -0800866@contextlib.contextmanager
867def tempdir():
868 tdir = None
869 try:
870 tdir = tempfile.mkdtemp(suffix='gerrit_util')
871 yield tdir
872 finally:
873 if tdir:
874 gclient_utils.rmtree(tdir)