blob: 75e5809e0f431b07cea228b1e231fea52ca96883 [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()
33TRY_LIMIT = 5
34
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000035
szager@chromium.orgb4696232013-10-16 19:45:35 +000036# Controls the transport protocol used to communicate with gerrit.
37# This is parameterized primarily to enable GerritTestCase.
38GERRIT_PROTOCOL = 'https'
39
40
41class GerritError(Exception):
42 """Exception class for errors commuicating with the gerrit-on-borg service."""
43 def __init__(self, http_status, *args, **kwargs):
44 super(GerritError, self).__init__(*args, **kwargs)
45 self.http_status = http_status
46 self.message = '(%d) %s' % (self.http_status, self.message)
47
48
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000049class GerritAuthenticationError(GerritError):
50 """Exception class for authentication errors during Gerrit communication."""
51
52
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020053def _QueryString(params, first_param=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000054 """Encodes query parameters in the key:val[+key:val...] format specified here:
55
56 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
57 """
58 q = [urllib.quote(first_param)] if first_param else []
Michael Achenbach6fbf12f2017-07-06 10:54:11 +020059 q.extend(['%s:%s' % (key, val) for key, val in params])
szager@chromium.orgb4696232013-10-16 19:45:35 +000060 return '+'.join(q)
61
62
Aaron Gabled2db5a22017-03-24 14:14:15 -070063def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000064 if protocol is None:
65 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010066 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070067 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000068 else:
69 raise RuntimeError(
70 "Don't know how to work with protocol '%s'" % protocol)
71
72
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000073class Authenticator(object):
74 """Base authenticator class for authenticator implementations to subclass."""
75
76 def get_auth_header(self, host):
77 raise NotImplementedError()
78
79 @staticmethod
80 def get():
81 """Returns: (Authenticator) The identified Authenticator to use.
82
83 Probes the local system and its environment and identifies the
84 Authenticator instance to use.
85 """
86 if GceAuthenticator.is_gce():
87 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000088 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000089
90
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000091class CookiesAuthenticator(Authenticator):
92 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
93
94 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000095 """
96
97 def __init__(self):
98 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000099 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000100
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000101 @classmethod
Andrii Shyshkalovc8173822017-07-10 12:10:53 +0200102 def get_new_password_url(cls, host):
103 assert not host.startswith('http')
104 # Assume *.googlesource.com pattern.
105 parts = host.split('.')
106 if not parts[0].endswith('-review'):
107 parts[0] += '-review'
108 return 'https://%s/new-password' % ('.'.join(parts))
109
110 @classmethod
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000111 def get_new_password_message(cls, host):
112 assert not host.startswith('http')
113 # Assume *.googlesource.com pattern.
114 parts = host.split('.')
115 if not parts[0].endswith('-review'):
116 parts[0] += '-review'
117 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100118 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000119
120 @classmethod
121 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000122 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000123 return os.path.expanduser(os.path.join('~', path))
124
125 @classmethod
126 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800127 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000128 path = cls.get_netrc_path()
Dan Jacques8d11e482016-11-15 14:25:56 -0800129 content = ''
130 if os.path.exists(path):
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000131 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000132 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
133 print >> sys.stderr, (
134 'WARNING: netrc file %s cannot be used because its file '
135 'permissions are insecure. netrc file permissions should be '
136 '600.' % path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800137 with open(path) as fd:
138 content = fd.read()
139
140 # Load the '.netrc' file. We strip comments from it because processing them
141 # can trigger a bug in Windows. See crbug.com/664664.
142 content = '\n'.join(l for l in content.splitlines()
143 if l.strip() and not l.strip().startswith('#'))
144 with tempdir() as tdir:
145 netrc_path = os.path.join(tdir, 'netrc')
146 with open(netrc_path, 'w') as fd:
147 fd.write(content)
148 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
149 return cls._get_netrc_from_path(netrc_path)
150
151 @classmethod
152 def _get_netrc_from_path(cls, path):
153 try:
154 return netrc.netrc(path)
155 except IOError:
156 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
157 return netrc.netrc(os.devnull)
158 except netrc.NetrcParseError as e:
159 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
160 'parsing error: %s' % (path, e))
161 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000162
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000163 @classmethod
164 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500165 if os.getenv('GIT_COOKIES_PATH'):
166 return os.getenv('GIT_COOKIES_PATH')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000167 return os.path.join(os.environ['HOME'], '.gitcookies')
168
169 @classmethod
170 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000171 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000172 path = cls.get_gitcookies_path()
173 if not os.path.exists(path):
174 return gitcookies
175
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000176 try:
177 f = open(path, 'rb')
178 except IOError:
179 return gitcookies
180
181 with f:
182 for line in f:
183 try:
184 fields = line.strip().split('\t')
185 if line.strip().startswith('#') or len(fields) != 7:
186 continue
187 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
188 if xpath == '/' and key == 'o':
189 login, secret_token = value.split('=', 1)
190 gitcookies[domain] = (login, secret_token)
191 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100192 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000193
194 return gitcookies
195
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100196 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000197 for domain, creds in self.gitcookies.iteritems():
198 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100199 return (creds[0], None, creds[1])
200 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000201
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100202 def get_auth_header(self, host):
203 auth = self._get_auth_for_host(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000204 if auth:
205 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
206 return None
207
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100208 def get_auth_email(self, host):
209 """Best effort parsing of email to be used for auth for the given host."""
210 auth = self._get_auth_for_host(host)
211 if not auth:
212 return None
213 login = auth[0]
214 # login typically looks like 'git-xxx.example.com'
215 if not login.startswith('git-') or '.' not in login:
216 return None
217 username, domain = login[len('git-'):].split('.', 1)
218 return '%s@%s' % (username, domain)
219
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100220
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000221# Backwards compatibility just in case somebody imports this outside of
222# depot_tools.
223NetrcAuthenticator = CookiesAuthenticator
224
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000225
226class GceAuthenticator(Authenticator):
227 """Authenticator implementation that uses GCE metadata service for token.
228 """
229
230 _INFO_URL = 'http://metadata.google.internal'
smut5e9401b2017-08-10 15:22:20 -0700231 _ACQUIRE_URL = ('%s/computeMetadata/v1/instance/'
232 'service-accounts/default/token' % _INFO_URL)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000233 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
234
235 _cache_is_gce = None
236 _token_cache = None
237 _token_expiration = None
238
239 @classmethod
240 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500241 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
242 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000243 if cls._cache_is_gce is None:
244 cls._cache_is_gce = cls._test_is_gce()
245 return cls._cache_is_gce
246
247 @classmethod
248 def _test_is_gce(cls):
249 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
250 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100251 resp, _ = cls._get(cls._INFO_URL)
252 except (socket.error, httplib2.ServerNotFoundError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000253 # Could not resolve URL.
254 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100255 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000256
257 @staticmethod
258 def _get(url, **kwargs):
259 next_delay_sec = 1
260 for i in xrange(TRY_LIMIT):
261 if i > 0:
262 # Retry server error status codes.
263 LOGGER.info('Encountered server error; retrying after %d second(s).',
264 next_delay_sec)
265 time.sleep(next_delay_sec)
266 next_delay_sec *= 2
267
268 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
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000275 @classmethod
276 def _get_token_dict(cls):
277 if cls._token_cache:
278 # If it expires within 25 seconds, refresh.
279 if cls._token_expiration < time.time() - 25:
280 return cls._token_cache
281
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100282 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000283 if resp.status != httplib.OK:
284 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100285 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000286 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
287 return cls._token_cache
288
289 def get_auth_header(self, _host):
290 token_dict = self._get_token_dict()
291 if not token_dict:
292 return None
293 return '%(token_type)s %(access_token)s' % token_dict
294
295
szager@chromium.orgb4696232013-10-16 19:45:35 +0000296def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
297 """Opens an https connection to a gerrit service, and sends a request."""
298 headers = headers or {}
299 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000300
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000301 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000302 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000303 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000304 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000305 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000306
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800307 url = path
308 if not url.startswith('/'):
309 url = '/' + url
310 if 'Authorization' in headers and not url.startswith('/a/'):
311 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000312
szager@chromium.orgb4696232013-10-16 19:45:35 +0000313 if body:
314 body = json.JSONEncoder().encode(body)
315 headers.setdefault('Content-Type', 'application/json')
316 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000317 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000318 for key, val in headers.iteritems():
319 if key == 'Authorization':
320 val = 'HIDDEN'
321 LOGGER.debug('%s: %s' % (key, val))
322 if body:
323 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700324 conn = GetConnectionObject()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000325 conn.req_host = host
326 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100327 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000328 'method': reqtype,
329 'headers': headers,
330 'body': body,
331 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000332 return conn
333
334
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700335def ReadHttpResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000336 """Reads an http response from a connection into a string buffer.
337
338 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100339 conn: An Http object created by CreateHttpConn above.
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700340 accept_statuses: Treat any of these statuses as success. Default: [200]
341 Common additions include 204, 400, and 404.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000342 Returns: A string buffer containing the connection's reply.
343 """
szager@chromium.orgb4696232013-10-16 19:45:35 +0000344 sleep_time = 0.5
345 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100346 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000347
348 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100349 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000350 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
351 www_authenticate):
352 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
353 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable19ee16c2017-04-18 11:56:35 -0700354 reason = ('Authentication failed. Please make sure your .gitcookies file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000355 'has credentials for %s' % host)
356 raise GerritAuthenticationError(response.status, reason)
357
szager@chromium.orgb4696232013-10-16 19:45:35 +0000358 # If response.status < 500 then the result is final; break retry loop.
Aaron Gable62ca9602017-05-19 17:24:52 -0700359 # If the response is 404, it might be because of replication lag, so
360 # keep trying anyway.
Michael Mossb40a4512017-10-10 11:07:17 -0700361 if ((response.status < 500 and response.status != 404)
362 or response.status in accept_statuses):
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100363 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100364 conn.req_params['method'], conn.req_params['uri'])
Michael Mossb40a4512017-10-10 11:07:17 -0700365 # If 404 was in accept_statuses, then it's expected that the file might
366 # not exist, so don't return the gitiles error page because that's not the
367 # "content" that was actually requested.
368 if response.status == 404:
369 contents = ''
szager@chromium.orgb4696232013-10-16 19:45:35 +0000370 break
371 # A status >=500 is assumed to be a possible transient error; retry.
372 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100373 LOGGER.warn('A transient error occurred while querying %s:\n'
374 '%s %s %s\n'
375 '%s %d %s',
Aaron Gable20d2cbb2017-04-25 15:04:05 -0700376 conn.req_host, conn.req_params['method'],
377 conn.req_params['uri'],
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100378 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000379 if TRY_LIMIT - idx > 1:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100380 LOGGER.warn('... will retry %d more times.', TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000381 time.sleep(sleep_time)
382 sleep_time = sleep_time * 2
Aaron Gable19ee16c2017-04-18 11:56:35 -0700383 if response.status not in accept_statuses:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200384 if response.status in (401, 403):
385 print('Your Gerrit credentials might be misconfigured. Try: \n'
386 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100387 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000388 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100389 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000390
391
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700392def ReadHttpJsonResponse(conn, accept_statuses=frozenset([200])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000393 """Parses an https response as json."""
Aaron Gable19ee16c2017-04-18 11:56:35 -0700394 fh = ReadHttpResponse(conn, accept_statuses)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000395 # The first line of the response should always be: )]}'
396 s = fh.readline()
397 if s and s.rstrip() != ")]}'":
398 raise GerritError(200, 'Unexpected json output: %s' % s)
399 s = fh.read()
400 if not s:
401 return None
402 return json.loads(s)
403
404
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200405def QueryChanges(host, params, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100406 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000407 """
408 Queries a gerrit-on-borg server for changes matching query terms.
409
410 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200411 params: A list of key:value pairs for search parameters, as documented
412 here (e.g. ('is', 'owner') for a parameter 'is:owner'):
413 https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
szager@chromium.orgb4696232013-10-16 19:45:35 +0000414 first_param: A change identifier
415 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100416 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000417 o_params: A list of additional output specifiers, as documented here:
418 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
419 Returns:
420 A list of json-decoded query results.
421 """
422 # Note that no attempt is made to escape special characters; YMMV.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200423 if not params and not first_param:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000424 raise RuntimeError('QueryChanges requires search parameters')
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200425 path = 'changes/?q=%s' % _QueryString(params, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100426 if start:
427 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000428 if limit:
429 path = '%s&n=%d' % (path, limit)
430 if o_params:
431 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700432 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000433
434
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200435def GenerateAllChanges(host, params, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100436 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000437 """
438 Queries a gerrit-on-borg server for all the changes matching the query terms.
439
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100440 WARNING: this is unreliable if a change matching the query is modified while
441 this function is being called.
442
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000443 A single query to gerrit-on-borg is limited on the number of results by the
444 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100445 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000446
447 Args:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200448 params, first_param: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000449 limit: Maximum number of requested changes per query.
450 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100451 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000452
453 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100454 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000455 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100456 already_returned = set()
457 def at_most_once(cls):
458 for cl in cls:
459 if cl['_number'] not in already_returned:
460 already_returned.add(cl['_number'])
461 yield cl
462
463 start = start or 0
464 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000465 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100466
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000467 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100468 # This will fetch changes[start..start+limit] sorted by most recently
469 # updated. Since the rank of any change in this list can be changed any time
470 # (say user posting comment), subsequent calls may overalp like this:
471 # > initial order ABCDEFGH
472 # query[0..3] => ABC
473 # > E get's updated. New order: EABCDFGH
474 # query[3..6] => CDF # C is a dup
475 # query[6..9] => GH # E is missed.
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200476 page = QueryChanges(host, params, first_param, limit, o_params,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100477 cur_start)
478 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000479 yield cl
480
481 more_changes = [cl for cl in page if '_more_changes' in cl]
482 if len(more_changes) > 1:
483 raise GerritError(
484 200,
485 'Received %d changes with a _more_changes attribute set but should '
486 'receive at most one.' % len(more_changes))
487 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100488 cur_start += len(page)
489
490 # If we paged through, query again the first page which in most circumstances
491 # will fetch all changes that were modified while this function was run.
492 if start != cur_start:
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200493 page = QueryChanges(host, params, first_param, limit, o_params, start)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100494 for cl in at_most_once(page):
495 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000496
497
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200498def MultiQueryChanges(host, params, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100499 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000500 """Initiate a query composed of multiple sets of query parameters."""
501 if not change_list:
502 raise RuntimeError(
503 "MultiQueryChanges requires a list of change numbers/id's")
504 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200505 if params:
506 q.append(_QueryString(params))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000507 if limit:
508 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100509 if start:
510 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000511 if o_params:
512 q.extend(['o=%s' % p for p in o_params])
513 path = 'changes/?%s' % '&'.join(q)
514 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700515 result = ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000516 except GerritError as e:
517 msg = '%s:\n%s' % (e.message, path)
518 raise GerritError(e.http_status, msg)
519 return result
520
521
522def GetGerritFetchUrl(host):
523 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
524 return '%s://%s/' % (GERRIT_PROTOCOL, host)
525
526
527def GetChangePageUrl(host, change_number):
528 """Given a gerrit host name and change number, return change page url."""
529 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
530
531
532def GetChangeUrl(host, change):
533 """Given a gerrit host name and change id, return an url for the change."""
534 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
535
536
537def GetChange(host, change):
538 """Query a gerrit server for information about a single change."""
539 path = 'changes/%s' % change
540 return ReadHttpJsonResponse(CreateHttpConn(host, path))
541
542
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700543def GetChangeDetail(host, change, o_params=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000544 """Query a gerrit server for extended information about a single change."""
545 path = 'changes/%s/detail' % change
546 if o_params:
547 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700548 return ReadHttpJsonResponse(CreateHttpConn(host, path))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000549
550
agable32978d92016-11-01 12:55:02 -0700551def GetChangeCommit(host, change, revision='current'):
552 """Query a gerrit server for a revision associated with a change."""
553 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
554 return ReadHttpJsonResponse(CreateHttpConn(host, path))
555
556
szager@chromium.orgb4696232013-10-16 19:45:35 +0000557def GetChangeCurrentRevision(host, change):
558 """Get information about the latest revision for a given change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200559 return QueryChanges(host, [], change, o_params=('CURRENT_REVISION',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000560
561
562def GetChangeRevisions(host, change):
563 """Get information about all revisions associated with a change."""
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200564 return QueryChanges(host, [], change, o_params=('ALL_REVISIONS',))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000565
566
567def GetChangeReview(host, change, revision=None):
568 """Get the current review information for a change."""
569 if not revision:
570 jmsg = GetChangeRevisions(host, change)
571 if not jmsg:
572 return None
573 elif len(jmsg) > 1:
574 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
575 revision = jmsg[0]['current_revision']
576 path = 'changes/%s/revisions/%s/review'
577 return ReadHttpJsonResponse(CreateHttpConn(host, path))
578
579
Aaron Gable0ffdf2d2017-06-05 13:01:17 -0700580def GetChangeComments(host, change):
581 """Get the line- and file-level comments on a change."""
582 path = 'changes/%s/comments' % change
583 return ReadHttpJsonResponse(CreateHttpConn(host, path))
584
585
szager@chromium.orgb4696232013-10-16 19:45:35 +0000586def AbandonChange(host, change, msg=''):
587 """Abandon a gerrit change."""
588 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000589 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000590 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700591 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000592
593
594def RestoreChange(host, change, msg=''):
595 """Restore a previously abandoned change."""
596 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000597 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000598 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700599 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000600
601
602def SubmitChange(host, change, wait_for_merge=True):
603 """Submits a gerrit change via Gerrit."""
604 path = 'changes/%s/submit' % change
605 body = {'wait_for_merge': wait_for_merge}
606 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700607 return ReadHttpJsonResponse(conn)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000608
609
dsansomee2d6fd92016-09-08 00:10:47 -0700610def HasPendingChangeEdit(host, change):
611 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
612 try:
Aaron Gable6f5a8d92017-04-18 14:49:05 -0700613 ReadHttpResponse(conn)
dsansomee2d6fd92016-09-08 00:10:47 -0700614 except GerritError as e:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700615 # 204 No Content means no pending change.
616 if e.http_status == 204:
617 return False
618 raise
619 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700620
621
622def DeletePendingChangeEdit(host, change):
623 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable19ee16c2017-04-18 11:56:35 -0700624 # On success, gerrit returns status 204; if the edit was already deleted it
625 # returns 404. Anything else is an error.
626 ReadHttpResponse(conn, accept_statuses=[204, 404])
dsansomee2d6fd92016-09-08 00:10:47 -0700627
628
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100629def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000630 """Updates a commit message."""
Aaron Gable7625d882017-06-26 09:47:26 -0700631 assert notify in ('ALL', 'NONE')
632 path = 'changes/%s/message' % change
Aaron Gable5a4ef452017-08-24 13:19:56 -0700633 body = {'message': description, 'notify': notify}
Aaron Gable7625d882017-06-26 09:47:26 -0700634 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000635 try:
Aaron Gable7625d882017-06-26 09:47:26 -0700636 ReadHttpResponse(conn, accept_statuses=[200, 204])
637 except GerritError as e:
638 raise GerritError(
639 e.http_status,
640 'Received unexpected http status while editing message '
641 'in change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000642
643
szager@chromium.orgb4696232013-10-16 19:45:35 +0000644def GetReviewers(host, change):
645 """Get information about all reviewers attached to a change."""
646 path = 'changes/%s/reviewers' % change
647 return ReadHttpJsonResponse(CreateHttpConn(host, path))
648
649
650def GetReview(host, change, revision):
651 """Get review information about a specific revision of a change."""
652 path = 'changes/%s/revisions/%s/review' % (change, revision)
653 return ReadHttpJsonResponse(CreateHttpConn(host, path))
654
655
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700656def AddReviewers(host, change, reviewers=None, ccs=None, notify=True,
657 accept_statuses=frozenset([200, 400, 422])):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000658 """Add reviewers to a change."""
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700659 if not reviewers and not ccs:
Aaron Gabledf86e302016-11-08 10:48:03 -0800660 return None
Wiktor Garbacz6d0d0442017-05-15 12:34:40 +0200661 if not change:
662 return None
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700663 reviewers = frozenset(reviewers or [])
664 ccs = frozenset(ccs or [])
665 path = 'changes/%s/revisions/current/review' % change
666
667 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800668 'drafts': 'KEEP',
Aaron Gable6dadfbf2017-05-09 14:27:58 -0700669 'reviewers': [],
670 'notify': 'ALL' if notify else 'NONE',
671 }
672 for r in sorted(reviewers | ccs):
673 state = 'REVIEWER' if r in reviewers else 'CC'
674 body['reviewers'].append({
675 'reviewer': r,
676 'state': state,
677 'notify': 'NONE', # We handled `notify` argument above.
678 })
679
680 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
681 # Gerrit will return 400 if one or more of the requested reviewers are
682 # unprocessable. We read the response object to see which were rejected,
683 # warn about them, and retry with the remainder.
684 resp = ReadHttpJsonResponse(conn, accept_statuses=accept_statuses)
685
686 errored = set()
687 for result in resp.get('reviewers', {}).itervalues():
688 r = result.get('input')
689 state = 'REVIEWER' if r in reviewers else 'CC'
690 if result.get('error'):
691 errored.add(r)
692 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
693 if errored:
694 # Try again, adding only those that didn't fail, and only accepting 200.
695 AddReviewers(host, change, reviewers=(reviewers-errored),
696 ccs=(ccs-errored), notify=notify, accept_statuses=[200])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000697
698
699def RemoveReviewers(host, change, remove=None):
700 """Remove reveiewers from a change."""
701 if not remove:
702 return
703 if isinstance(remove, basestring):
704 remove = (remove,)
705 for r in remove:
706 path = 'changes/%s/reviewers/%s' % (change, r)
707 conn = CreateHttpConn(host, path, reqtype='DELETE')
708 try:
Aaron Gable19ee16c2017-04-18 11:56:35 -0700709 ReadHttpResponse(conn, accept_statuses=[204])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000710 except GerritError as e:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000711 raise GerritError(
Aaron Gable19ee16c2017-04-18 11:56:35 -0700712 e.http_status,
713 'Received unexpected http status while deleting reviewer "%s" '
714 'from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000715
716
Aaron Gable636b13f2017-07-14 10:42:48 -0700717def SetReview(host, change, msg=None, labels=None, notify=None, ready=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000718 """Set labels and/or add a message to a code review."""
719 if not msg and not labels:
720 return
721 path = 'changes/%s/revisions/current/review' % change
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800722 body = {'drafts': 'KEEP'}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000723 if msg:
724 body['message'] = msg
725 if labels:
726 body['labels'] = labels
Aaron Gablefc62f762017-07-17 11:12:07 -0700727 if notify is not None:
Aaron Gable75e78722017-06-09 10:40:16 -0700728 body['notify'] = 'ALL' if notify else 'NONE'
Aaron Gable636b13f2017-07-14 10:42:48 -0700729 if ready:
730 body['ready'] = True
szager@chromium.orgb4696232013-10-16 19:45:35 +0000731 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
732 response = ReadHttpJsonResponse(conn)
733 if labels:
734 for key, val in labels.iteritems():
735 if ('labels' not in response or key not in response['labels'] or
736 int(response['labels'][key] != int(val))):
737 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
738 key, change))
739
740
741def ResetReviewLabels(host, change, label, value='0', message=None,
742 notify=None):
743 """Reset the value of a given label for all reviewers on a change."""
744 # This is tricky, because we want to work on the "current revision", but
745 # there's always the risk that "current revision" will change in between
746 # API calls. So, we check "current revision" at the beginning and end; if
747 # it has changed, raise an exception.
748 jmsg = GetChangeCurrentRevision(host, change)
749 if not jmsg:
750 raise GerritError(
751 200, 'Could not get review information for change "%s"' % change)
752 value = str(value)
753 revision = jmsg[0]['current_revision']
754 path = 'changes/%s/revisions/%s/review' % (change, revision)
755 message = message or (
756 '%s label set to %s programmatically.' % (label, value))
757 jmsg = GetReview(host, change, revision)
758 if not jmsg:
759 raise GerritError(200, 'Could not get review information for revison %s '
760 'of change %s' % (revision, change))
761 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
762 if str(review.get('value', value)) != value:
763 body = {
Jonathan Nieder1ea21322017-11-10 11:45:42 -0800764 'drafts': 'KEEP',
szager@chromium.orgb4696232013-10-16 19:45:35 +0000765 'message': message,
766 'labels': {label: value},
767 'on_behalf_of': review['_account_id'],
768 }
769 if notify:
770 body['notify'] = notify
771 conn = CreateHttpConn(
772 host, path, reqtype='POST', body=body)
773 response = ReadHttpJsonResponse(conn)
774 if str(response['labels'][label]) != value:
775 username = review.get('email', jmsg.get('name', ''))
776 raise GerritError(200, 'Unable to set %s label for user "%s"'
777 ' on change %s.' % (label, username, change))
778 jmsg = GetChangeCurrentRevision(host, change)
779 if not jmsg:
780 raise GerritError(
781 200, 'Could not get review information for change "%s"' % change)
782 elif jmsg[0]['current_revision'] != revision:
783 raise GerritError(200, 'While resetting labels on change "%s", '
784 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800785
786
dimu833c94c2017-01-18 17:36:15 -0800787def CreateGerritBranch(host, project, branch, commit):
788 """
789 Create a new branch from given project and commit
790 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
791
792 Returns:
793 A JSON with 'ref' key
794 """
795 path = 'projects/%s/branches/%s' % (project, branch)
796 body = {'revision': commit}
797 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
dimu7d1af2b2017-04-19 16:01:17 -0700798 response = ReadHttpJsonResponse(conn, accept_statuses=[201])
dimu833c94c2017-01-18 17:36:15 -0800799 if response:
800 return response
801 raise GerritError(200, 'Unable to create gerrit branch')
802
803
804def GetGerritBranch(host, project, branch):
805 """
806 Get a branch from given project and commit
807 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
808
809 Returns:
810 A JSON object with 'revision' key
811 """
812 path = 'projects/%s/branches/%s' % (project, branch)
813 conn = CreateHttpConn(host, path, reqtype='GET')
814 response = ReadHttpJsonResponse(conn)
815 if response:
816 return response
817 raise GerritError(200, 'Unable to get gerrit branch')
818
819
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100820def GetAccountDetails(host, account_id='self'):
821 """Returns details of the account.
822
823 If account_id is not given, uses magic value 'self' which corresponds to
824 whichever account user is authenticating as.
825
826 Documentation:
827 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
828 """
829 if account_id != 'self':
830 account_id = int(account_id)
831 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
832 return ReadHttpJsonResponse(conn)
833
834
Nick Carter8692b182017-11-06 16:30:38 -0800835def PercentEncodeForGitRef(original):
836 """Apply percent-encoding for strings sent to gerrit via git ref metadata.
837
838 The encoding used is based on but stricter than URL encoding (Section 2.1
839 of RFC 3986). The only non-escaped characters are alphanumerics, and
840 'SPACE' (U+0020) can be represented as 'LOW LINE' (U+005F) or
841 'PLUS SIGN' (U+002B).
842
843 For more information, see the Gerrit docs here:
844
845 https://gerrit-review.googlesource.com/Documentation/user-upload.html#message
846 """
847 safe = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
848 encoded = ''.join(c if c in safe else '%%%02X' % ord(c) for c in original)
849
850 # spaces are not allowed in git refs; gerrit will interpret either '_' or
851 # '+' (or '%20') as space. Use '_' since that has been supported the longest.
852 return encoded.replace(' ', '_')
853
854
Dan Jacques8d11e482016-11-15 14:25:56 -0800855@contextlib.contextmanager
856def tempdir():
857 tdir = None
858 try:
859 tdir = tempfile.mkdtemp(suffix='gerrit_util')
860 yield tdir
861 finally:
862 if tdir:
863 gclient_utils.rmtree(tdir)