blob: dad636f0d79fc471e6d65a7b32798b480659dca5 [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
szager@chromium.orgb4696232013-10-16 19:45:35 +000014import httplib
15import json
16import logging
17import netrc
18import os
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000019import re
Dan Jacques8d11e482016-11-15 14:25:56 -080020import shutil
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000021import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000022import stat
23import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080024import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000025import time
26import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000027import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000028from cStringIO import StringIO
29
Dan Jacques8d11e482016-11-15 14:25:56 -080030import gclient_utils
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
Dan Jacques1d949fd2016-11-15 10:41:48 -080041
szager@chromium.orgb4696232013-10-16 19:45:35 +000042class GerritError(Exception):
43 """Exception class for errors commuicating with the gerrit-on-borg service."""
44 def __init__(self, http_status, *args, **kwargs):
45 super(GerritError, self).__init__(*args, **kwargs)
46 self.http_status = http_status
47 self.message = '(%d) %s' % (self.http_status, self.message)
48
49
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000050class GerritAuthenticationError(GerritError):
51 """Exception class for authentication errors during Gerrit communication."""
52
53
szager@chromium.orgb4696232013-10-16 19:45:35 +000054def _QueryString(param_dict, first_param=None):
55 """Encodes query parameters in the key:val[+key:val...] format specified here:
56
57 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
58 """
59 q = [urllib.quote(first_param)] if first_param else []
60 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
61 return '+'.join(q)
62
63
64def GetConnectionClass(protocol=None):
65 if protocol is None:
66 protocol = GERRIT_PROTOCOL
67 if protocol == 'https':
68 return httplib.HTTPSConnection
69 elif protocol == 'http':
70 return httplib.HTTPConnection
71 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
105 def get_new_password_message(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 url = 'https://%s/new-password' % ('.'.join(parts))
112 return 'You can (re)generate your credentails by visiting %s' % url
113
114 @classmethod
115 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000116 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000117 return os.path.expanduser(os.path.join('~', path))
118
119 @classmethod
120 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800121 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000122 path = cls.get_netrc_path()
Dan Jacques8d11e482016-11-15 14:25:56 -0800123 content = ''
124 if os.path.exists(path):
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000125 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000126 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
127 print >> sys.stderr, (
128 'WARNING: netrc file %s cannot be used because its file '
129 'permissions are insecure. netrc file permissions should be '
130 '600.' % path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800131 with open(path) as fd:
132 content = fd.read()
133
134 # Load the '.netrc' file. We strip comments from it because processing them
135 # can trigger a bug in Windows. See crbug.com/664664.
136 content = '\n'.join(l for l in content.splitlines()
137 if l.strip() and not l.strip().startswith('#'))
138 with tempdir() as tdir:
139 netrc_path = os.path.join(tdir, 'netrc')
140 with open(netrc_path, 'w') as fd:
141 fd.write(content)
142 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
143 return cls._get_netrc_from_path(netrc_path)
144
145 @classmethod
146 def _get_netrc_from_path(cls, path):
147 try:
148 return netrc.netrc(path)
149 except IOError:
150 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
151 return netrc.netrc(os.devnull)
152 except netrc.NetrcParseError as e:
153 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
154 'parsing error: %s' % (path, e))
155 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000156
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000157 @classmethod
158 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500159 if os.getenv('GIT_COOKIES_PATH'):
160 return os.getenv('GIT_COOKIES_PATH')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000161 return os.path.join(os.environ['HOME'], '.gitcookies')
162
163 @classmethod
164 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000165 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000166 path = cls.get_gitcookies_path()
167 if not os.path.exists(path):
168 return gitcookies
169
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000170 try:
171 f = open(path, 'rb')
172 except IOError:
173 return gitcookies
174
175 with f:
176 for line in f:
177 try:
178 fields = line.strip().split('\t')
179 if line.strip().startswith('#') or len(fields) != 7:
180 continue
181 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
182 if xpath == '/' and key == 'o':
183 login, secret_token = value.split('=', 1)
184 gitcookies[domain] = (login, secret_token)
185 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100186 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000187
188 return gitcookies
189
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000190 def get_auth_header(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000191 auth = None
192 for domain, creds in self.gitcookies.iteritems():
193 if cookielib.domain_match(host, domain):
194 auth = (creds[0], None, creds[1])
195 break
196
197 if not auth:
198 auth = self.netrc.authenticators(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000199 if auth:
200 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
201 return None
202
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000203# Backwards compatibility just in case somebody imports this outside of
204# depot_tools.
205NetrcAuthenticator = CookiesAuthenticator
206
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000207
208class GceAuthenticator(Authenticator):
209 """Authenticator implementation that uses GCE metadata service for token.
210 """
211
212 _INFO_URL = 'http://metadata.google.internal'
213 _ACQUIRE_URL = ('http://metadata/computeMetadata/v1/instance/'
214 'service-accounts/default/token')
215 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
216
217 _cache_is_gce = None
218 _token_cache = None
219 _token_expiration = None
220
221 @classmethod
222 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500223 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
224 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000225 if cls._cache_is_gce is None:
226 cls._cache_is_gce = cls._test_is_gce()
227 return cls._cache_is_gce
228
229 @classmethod
230 def _test_is_gce(cls):
231 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
232 try:
233 resp = cls._get(cls._INFO_URL)
234 except socket.error:
235 # Could not resolve URL.
236 return False
237 return resp.getheader('Metadata-Flavor', None) == 'Google'
238
239 @staticmethod
240 def _get(url, **kwargs):
241 next_delay_sec = 1
242 for i in xrange(TRY_LIMIT):
243 if i > 0:
244 # Retry server error status codes.
245 LOGGER.info('Encountered server error; retrying after %d second(s).',
246 next_delay_sec)
247 time.sleep(next_delay_sec)
248 next_delay_sec *= 2
249
250 p = urlparse.urlparse(url)
251 c = GetConnectionClass(protocol=p.scheme)(p.netloc)
252 c.request('GET', url, **kwargs)
253 resp = c.getresponse()
254 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
255 if resp.status < httplib.INTERNAL_SERVER_ERROR:
256 return resp
257
258
259 @classmethod
260 def _get_token_dict(cls):
261 if cls._token_cache:
262 # If it expires within 25 seconds, refresh.
263 if cls._token_expiration < time.time() - 25:
264 return cls._token_cache
265
266 resp = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
267 if resp.status != httplib.OK:
268 return None
269 cls._token_cache = json.load(resp)
270 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
271 return cls._token_cache
272
273 def get_auth_header(self, _host):
274 token_dict = self._get_token_dict()
275 if not token_dict:
276 return None
277 return '%(token_type)s %(access_token)s' % token_dict
278
279
280
szager@chromium.orgb4696232013-10-16 19:45:35 +0000281def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
282 """Opens an https connection to a gerrit service, and sends a request."""
283 headers = headers or {}
284 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000285
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000286 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000287 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000288 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000289 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000290 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000291
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800292 url = path
293 if not url.startswith('/'):
294 url = '/' + url
295 if 'Authorization' in headers and not url.startswith('/a/'):
296 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000297
szager@chromium.orgb4696232013-10-16 19:45:35 +0000298 if body:
299 body = json.JSONEncoder().encode(body)
300 headers.setdefault('Content-Type', 'application/json')
301 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000302 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000303 for key, val in headers.iteritems():
304 if key == 'Authorization':
305 val = 'HIDDEN'
306 LOGGER.debug('%s: %s' % (key, val))
307 if body:
308 LOGGER.debug(body)
309 conn = GetConnectionClass()(host)
310 conn.req_host = host
311 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000312 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000313 'method': reqtype,
314 'headers': headers,
315 'body': body,
316 }
317 conn.request(**conn.req_params)
318 return conn
319
320
321def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
322 """Reads an http response from a connection into a string buffer.
323
324 Args:
325 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
326 expect_status: Success is indicated by this status in the response.
327 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
328 doesn't match the database contents. In most such cases, we
329 want the API to return None rather than raise an Exception.
330 Returns: A string buffer containing the connection's reply.
331 """
332
333 sleep_time = 0.5
334 for idx in range(TRY_LIMIT):
335 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000336
337 # Check if this is an authentication issue.
338 www_authenticate = response.getheader('www-authenticate')
339 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
340 www_authenticate):
341 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
342 host = auth_match.group(1) if auth_match else conn.req_host
343 reason = ('Authentication failed. Please make sure your .netrc file '
344 'has credentials for %s' % host)
345 raise GerritAuthenticationError(response.status, reason)
346
szager@chromium.orgb4696232013-10-16 19:45:35 +0000347 # If response.status < 500 then the result is final; break retry loop.
348 if response.status < 500:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100349 LOGGER.debug('got response %d for %s %s', response.status,
350 conn.req_params['method'], conn.req_params['url'])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000351 break
352 # A status >=500 is assumed to be a possible transient error; retry.
353 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100354 LOGGER.warn('A transient error occurred while querying %s:\n'
355 '%s %s %s\n'
356 '%s %d %s',
357 conn.host, conn.req_params['method'], conn.req_params['url'],
358 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000359 if TRY_LIMIT - idx > 1:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100360 LOGGER.warn('... will retry %d more times.', TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000361 time.sleep(sleep_time)
362 sleep_time = sleep_time * 2
363 req_host = conn.req_host
364 req_params = conn.req_params
365 conn = GetConnectionClass()(req_host)
366 conn.req_host = req_host
367 conn.req_params = req_params
368 conn.request(**req_params)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000369 if ignore_404 and response.status == 404:
370 return StringIO()
371 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000372 reason = '%s: %s' % (response.reason, response.read())
373 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000374 return StringIO(response.read())
375
376
377def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
378 """Parses an https response as json."""
379 fh = ReadHttpResponse(
380 conn, expect_status=expect_status, ignore_404=ignore_404)
381 # The first line of the response should always be: )]}'
382 s = fh.readline()
383 if s and s.rstrip() != ")]}'":
384 raise GerritError(200, 'Unexpected json output: %s' % s)
385 s = fh.read()
386 if not s:
387 return None
388 return json.loads(s)
389
390
391def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
392 sortkey=None):
393 """
394 Queries a gerrit-on-borg server for changes matching query terms.
395
396 Args:
397 param_dict: A dictionary of search parameters, as documented here:
398 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
399 first_param: A change identifier
400 limit: Maximum number of results to return.
401 o_params: A list of additional output specifiers, as documented here:
402 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
403 Returns:
404 A list of json-decoded query results.
405 """
406 # Note that no attempt is made to escape special characters; YMMV.
407 if not param_dict and not first_param:
408 raise RuntimeError('QueryChanges requires search parameters')
409 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
410 if sortkey:
411 path = '%s&N=%s' % (path, sortkey)
412 if limit:
413 path = '%s&n=%d' % (path, limit)
414 if o_params:
415 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
416 # Don't ignore 404; a query should always return a list, even if it's empty.
417 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
418
419
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000420def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
421 o_params=None, sortkey=None):
422 """
423 Queries a gerrit-on-borg server for all the changes matching the query terms.
424
425 A single query to gerrit-on-borg is limited on the number of results by the
426 limit parameter on the request (see QueryChanges) and the server maximum
427 limit. This function uses the "_more_changes" and "_sortkey" attributes on
428 the returned changes to iterate all of them making multiple queries to the
429 server, regardless the query limit.
430
431 Args:
432 param_dict, first_param: Refer to QueryChanges().
433 limit: Maximum number of requested changes per query.
434 o_params: Refer to QueryChanges().
435 sortkey: The value of the "_sortkey" attribute where starts from. None to
436 start from the first change.
437
438 Returns:
439 A generator object to the list of returned changes, possibly unbound.
440 """
441 more_changes = True
442 while more_changes:
443 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
444 for cl in page:
445 yield cl
446
447 more_changes = [cl for cl in page if '_more_changes' in cl]
448 if len(more_changes) > 1:
449 raise GerritError(
450 200,
451 'Received %d changes with a _more_changes attribute set but should '
452 'receive at most one.' % len(more_changes))
453 if more_changes:
454 sortkey = more_changes[0]['_sortkey']
455
456
szager@chromium.orgb4696232013-10-16 19:45:35 +0000457def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
458 sortkey=None):
459 """Initiate a query composed of multiple sets of query parameters."""
460 if not change_list:
461 raise RuntimeError(
462 "MultiQueryChanges requires a list of change numbers/id's")
463 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
464 if param_dict:
465 q.append(_QueryString(param_dict))
466 if limit:
467 q.append('n=%d' % limit)
468 if sortkey:
469 q.append('N=%s' % sortkey)
470 if o_params:
471 q.extend(['o=%s' % p for p in o_params])
472 path = 'changes/?%s' % '&'.join(q)
473 try:
474 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
475 except GerritError as e:
476 msg = '%s:\n%s' % (e.message, path)
477 raise GerritError(e.http_status, msg)
478 return result
479
480
481def GetGerritFetchUrl(host):
482 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
483 return '%s://%s/' % (GERRIT_PROTOCOL, host)
484
485
486def GetChangePageUrl(host, change_number):
487 """Given a gerrit host name and change number, return change page url."""
488 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
489
490
491def GetChangeUrl(host, change):
492 """Given a gerrit host name and change id, return an url for the change."""
493 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
494
495
496def GetChange(host, change):
497 """Query a gerrit server for information about a single change."""
498 path = 'changes/%s' % change
499 return ReadHttpJsonResponse(CreateHttpConn(host, path))
500
501
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100502def GetChangeDetail(host, change, o_params=None, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000503 """Query a gerrit server for extended information about a single change."""
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100504 # TODO(tandrii): cahnge ignore_404 to False by default.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000505 path = 'changes/%s/detail' % change
506 if o_params:
507 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100508 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=ignore_404)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000509
510
agable32978d92016-11-01 12:55:02 -0700511def GetChangeCommit(host, change, revision='current'):
512 """Query a gerrit server for a revision associated with a change."""
513 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
514 return ReadHttpJsonResponse(CreateHttpConn(host, path))
515
516
szager@chromium.orgb4696232013-10-16 19:45:35 +0000517def GetChangeCurrentRevision(host, change):
518 """Get information about the latest revision for a given change."""
519 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
520
521
522def GetChangeRevisions(host, change):
523 """Get information about all revisions associated with a change."""
524 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
525
526
527def GetChangeReview(host, change, revision=None):
528 """Get the current review information for a change."""
529 if not revision:
530 jmsg = GetChangeRevisions(host, change)
531 if not jmsg:
532 return None
533 elif len(jmsg) > 1:
534 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
535 revision = jmsg[0]['current_revision']
536 path = 'changes/%s/revisions/%s/review'
537 return ReadHttpJsonResponse(CreateHttpConn(host, path))
538
539
540def AbandonChange(host, change, msg=''):
541 """Abandon a gerrit change."""
542 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000543 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000544 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
545 return ReadHttpJsonResponse(conn, ignore_404=False)
546
547
548def RestoreChange(host, change, msg=''):
549 """Restore a previously abandoned change."""
550 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000551 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000552 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
553 return ReadHttpJsonResponse(conn, ignore_404=False)
554
555
556def SubmitChange(host, change, wait_for_merge=True):
557 """Submits a gerrit change via Gerrit."""
558 path = 'changes/%s/submit' % change
559 body = {'wait_for_merge': wait_for_merge}
560 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
561 return ReadHttpJsonResponse(conn, ignore_404=False)
562
563
dsansomee2d6fd92016-09-08 00:10:47 -0700564def HasPendingChangeEdit(host, change):
565 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
566 try:
567 ReadHttpResponse(conn, ignore_404=False)
568 except GerritError as e:
569 # On success, gerrit returns status 204; anything else is an error.
570 if e.http_status != 204:
571 raise
572 return False
573 else:
574 return True
575
576
577def DeletePendingChangeEdit(host, change):
578 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
579 try:
580 ReadHttpResponse(conn, ignore_404=False)
581 except GerritError as e:
582 # On success, gerrit returns status 204; if the edit was already deleted it
583 # returns 404. Anything else is an error.
584 if e.http_status not in (204, 404):
585 raise
586
587
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100588def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000589 """Updates a commit message."""
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000590 try:
Aaron Gablee9373d62016-12-13 12:28:45 -0800591 assert notify in ('ALL', 'NONE')
592 # First, edit the commit message in a draft.
593 path = 'changes/%s/edit:message' % change
594 body = {'message': description}
595 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
596 try:
597 ReadHttpResponse(conn, ignore_404=False)
598 except GerritError as e:
599 # On success, gerrit returns status 204; anything else is an error.
600 if e.http_status != 204:
601 raise
602 else:
603 raise GerritError(
604 'Unexpectedly received a 200 http status while editing message in '
605 'change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000606
Aaron Gablee9373d62016-12-13 12:28:45 -0800607 # And then publish it.
608 path = 'changes/%s/edit:publish' % change
609 conn = CreateHttpConn(host, path, reqtype='POST', body={'notify': notify})
610 try:
611 ReadHttpResponse(conn, ignore_404=False)
612 except GerritError as e:
613 # On success, gerrit returns status 204; anything else is an error.
614 if e.http_status != 204:
615 raise
616 else:
617 raise GerritError(
618 'Unexpectedly received a 200 http status while publishing message '
619 'change in %s' % change)
620 except (GerritError, KeyboardInterrupt) as e:
621 # Something went wrong with one of the two calls, so we want to clean up
622 # after ourselves before returning.
623 try:
624 DeletePendingChangeEdit(host, change)
625 except GerritError:
626 LOGGER.error('Encountered error while cleaning up after failed attempt '
627 'to set the CL description. You may have to delete the '
628 'pending change edit yourself in the web UI.')
629 raise e
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000630
631
szager@chromium.orgb4696232013-10-16 19:45:35 +0000632def GetReviewers(host, change):
633 """Get information about all reviewers attached to a change."""
634 path = 'changes/%s/reviewers' % change
635 return ReadHttpJsonResponse(CreateHttpConn(host, path))
636
637
638def GetReview(host, change, revision):
639 """Get review information about a specific revision of a change."""
640 path = 'changes/%s/revisions/%s/review' % (change, revision)
641 return ReadHttpJsonResponse(CreateHttpConn(host, path))
642
643
Aaron Gable59f48512017-01-12 10:54:46 -0800644def AddReviewers(host, change, add=None, is_reviewer=True, notify=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000645 """Add reviewers to a change."""
Aaron Gabledf86e302016-11-08 10:48:03 -0800646 errors = None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000647 if not add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800648 return None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000649 if isinstance(add, basestring):
650 add = (add,)
651 path = 'changes/%s/reviewers' % change
652 for r in add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800653 state = 'REVIEWER' if is_reviewer else 'CC'
Aaron Gable59f48512017-01-12 10:54:46 -0800654 notify = 'ALL' if notify else 'NONE'
tandrii88189772016-09-29 04:29:57 -0700655 body = {
656 'reviewer': r,
Aaron Gabledf86e302016-11-08 10:48:03 -0800657 'state': state,
Aaron Gable59f48512017-01-12 10:54:46 -0800658 'notify': notify,
tandrii88189772016-09-29 04:29:57 -0700659 }
Aaron Gabledf86e302016-11-08 10:48:03 -0800660 try:
661 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
662 _ = ReadHttpJsonResponse(conn, ignore_404=False)
663 except GerritError as e:
664 if e.http_status == 422: # "Unprocessable Entity"
665 LOGGER.warn('Failed to add "%s" as a %s' % (r, state.lower()))
666 errors = True
667 else:
668 raise
669 return errors
szager@chromium.orgb4696232013-10-16 19:45:35 +0000670
671
672def RemoveReviewers(host, change, remove=None):
673 """Remove reveiewers from a change."""
674 if not remove:
675 return
676 if isinstance(remove, basestring):
677 remove = (remove,)
678 for r in remove:
679 path = 'changes/%s/reviewers/%s' % (change, r)
680 conn = CreateHttpConn(host, path, reqtype='DELETE')
681 try:
682 ReadHttpResponse(conn, ignore_404=False)
683 except GerritError as e:
684 # On success, gerrit returns status 204; anything else is an error.
685 if e.http_status != 204:
686 raise
687 else:
688 raise GerritError(
689 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
690 ' from change %s' % (r, change))
691
692
693def SetReview(host, change, msg=None, labels=None, notify=None):
694 """Set labels and/or add a message to a code review."""
695 if not msg and not labels:
696 return
697 path = 'changes/%s/revisions/current/review' % change
698 body = {}
699 if msg:
700 body['message'] = msg
701 if labels:
702 body['labels'] = labels
703 if notify:
704 body['notify'] = notify
705 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
706 response = ReadHttpJsonResponse(conn)
707 if labels:
708 for key, val in labels.iteritems():
709 if ('labels' not in response or key not in response['labels'] or
710 int(response['labels'][key] != int(val))):
711 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
712 key, change))
713
714
715def ResetReviewLabels(host, change, label, value='0', message=None,
716 notify=None):
717 """Reset the value of a given label for all reviewers on a change."""
718 # This is tricky, because we want to work on the "current revision", but
719 # there's always the risk that "current revision" will change in between
720 # API calls. So, we check "current revision" at the beginning and end; if
721 # it has changed, raise an exception.
722 jmsg = GetChangeCurrentRevision(host, change)
723 if not jmsg:
724 raise GerritError(
725 200, 'Could not get review information for change "%s"' % change)
726 value = str(value)
727 revision = jmsg[0]['current_revision']
728 path = 'changes/%s/revisions/%s/review' % (change, revision)
729 message = message or (
730 '%s label set to %s programmatically.' % (label, value))
731 jmsg = GetReview(host, change, revision)
732 if not jmsg:
733 raise GerritError(200, 'Could not get review information for revison %s '
734 'of change %s' % (revision, change))
735 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
736 if str(review.get('value', value)) != value:
737 body = {
738 'message': message,
739 'labels': {label: value},
740 'on_behalf_of': review['_account_id'],
741 }
742 if notify:
743 body['notify'] = notify
744 conn = CreateHttpConn(
745 host, path, reqtype='POST', body=body)
746 response = ReadHttpJsonResponse(conn)
747 if str(response['labels'][label]) != value:
748 username = review.get('email', jmsg.get('name', ''))
749 raise GerritError(200, 'Unable to set %s label for user "%s"'
750 ' on change %s.' % (label, username, change))
751 jmsg = GetChangeCurrentRevision(host, change)
752 if not jmsg:
753 raise GerritError(
754 200, 'Could not get review information for change "%s"' % change)
755 elif jmsg[0]['current_revision'] != revision:
756 raise GerritError(200, 'While resetting labels on change "%s", '
757 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800758
759
dimu833c94c2017-01-18 17:36:15 -0800760def CreateGerritBranch(host, project, branch, commit):
761 """
762 Create a new branch from given project and commit
763 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
764
765 Returns:
766 A JSON with 'ref' key
767 """
768 path = 'projects/%s/branches/%s' % (project, branch)
769 body = {'revision': commit}
770 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
771 response = ReadHttpJsonResponse(conn)
772 if response:
773 return response
774 raise GerritError(200, 'Unable to create gerrit branch')
775
776
777def GetGerritBranch(host, project, branch):
778 """
779 Get a branch from given project and commit
780 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
781
782 Returns:
783 A JSON object with 'revision' key
784 """
785 path = 'projects/%s/branches/%s' % (project, branch)
786 conn = CreateHttpConn(host, path, reqtype='GET')
787 response = ReadHttpJsonResponse(conn)
788 if response:
789 return response
790 raise GerritError(200, 'Unable to get gerrit branch')
791
792
Dan Jacques8d11e482016-11-15 14:25:56 -0800793@contextlib.contextmanager
794def tempdir():
795 tdir = None
796 try:
797 tdir = tempfile.mkdtemp(suffix='gerrit_util')
798 yield tdir
799 finally:
800 if tdir:
801 gclient_utils.rmtree(tdir)