blob: 6a79e8cfbfea695b1ffa0fb109d5da88dde59cf7 [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
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
szager@chromium.orgf202a252014-05-27 18:55:52 +000030
szager@chromium.orgb4696232013-10-16 19:45:35 +000031LOGGER = logging.getLogger()
32TRY_LIMIT = 5
33
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000034
szager@chromium.orgb4696232013-10-16 19:45:35 +000035# Controls the transport protocol used to communicate with gerrit.
36# This is parameterized primarily to enable GerritTestCase.
37GERRIT_PROTOCOL = 'https'
38
39
40class GerritError(Exception):
41 """Exception class for errors commuicating with the gerrit-on-borg service."""
42 def __init__(self, http_status, *args, **kwargs):
43 super(GerritError, self).__init__(*args, **kwargs)
44 self.http_status = http_status
45 self.message = '(%d) %s' % (self.http_status, self.message)
46
47
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000048class GerritAuthenticationError(GerritError):
49 """Exception class for authentication errors during Gerrit communication."""
50
51
szager@chromium.orgb4696232013-10-16 19:45:35 +000052def _QueryString(param_dict, first_param=None):
53 """Encodes query parameters in the key:val[+key:val...] format specified here:
54
55 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
56 """
57 q = [urllib.quote(first_param)] if first_param else []
58 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
59 return '+'.join(q)
60
61
62def GetConnectionClass(protocol=None):
63 if protocol is None:
64 protocol = GERRIT_PROTOCOL
65 if protocol == 'https':
66 return httplib.HTTPSConnection
67 elif protocol == 'http':
68 return httplib.HTTPConnection
69 else:
70 raise RuntimeError(
71 "Don't know how to work with protocol '%s'" % protocol)
72
73
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000074class Authenticator(object):
75 """Base authenticator class for authenticator implementations to subclass."""
76
77 def get_auth_header(self, host):
78 raise NotImplementedError()
79
80 @staticmethod
81 def get():
82 """Returns: (Authenticator) The identified Authenticator to use.
83
84 Probes the local system and its environment and identifies the
85 Authenticator instance to use.
86 """
87 if GceAuthenticator.is_gce():
88 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000089 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000090
91
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000092class CookiesAuthenticator(Authenticator):
93 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
94
95 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000096 """
97
98 def __init__(self):
99 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000100 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000101
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000102 @classmethod
103 def get_new_password_message(cls, host):
104 assert not host.startswith('http')
105 # Assume *.googlesource.com pattern.
106 parts = host.split('.')
107 if not parts[0].endswith('-review'):
108 parts[0] += '-review'
109 url = 'https://%s/new-password' % ('.'.join(parts))
110 return 'You can (re)generate your credentails by visiting %s' % url
111
112 @classmethod
113 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000114 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000115 return os.path.expanduser(os.path.join('~', path))
116
117 @classmethod
118 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800119 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000120 path = cls.get_netrc_path()
Dan Jacques8d11e482016-11-15 14:25:56 -0800121 content = ''
122 if os.path.exists(path):
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000123 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000124 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
125 print >> sys.stderr, (
126 'WARNING: netrc file %s cannot be used because its file '
127 'permissions are insecure. netrc file permissions should be '
128 '600.' % path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800129 with open(path) as fd:
130 content = fd.read()
131
132 # Load the '.netrc' file. We strip comments from it because processing them
133 # can trigger a bug in Windows. See crbug.com/664664.
134 content = '\n'.join(l for l in content.splitlines()
135 if l.strip() and not l.strip().startswith('#'))
136 with tempdir() as tdir:
137 netrc_path = os.path.join(tdir, 'netrc')
138 with open(netrc_path, 'w') as fd:
139 fd.write(content)
140 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
141 return cls._get_netrc_from_path(netrc_path)
142
143 @classmethod
144 def _get_netrc_from_path(cls, path):
145 try:
146 return netrc.netrc(path)
147 except IOError:
148 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
149 return netrc.netrc(os.devnull)
150 except netrc.NetrcParseError as e:
151 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
152 'parsing error: %s' % (path, e))
153 return netrc.netrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000154
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000155 @classmethod
156 def get_gitcookies_path(cls):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500157 if os.getenv('GIT_COOKIES_PATH'):
158 return os.getenv('GIT_COOKIES_PATH')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000159 return os.path.join(os.environ['HOME'], '.gitcookies')
160
161 @classmethod
162 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000163 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000164 path = cls.get_gitcookies_path()
165 if not os.path.exists(path):
166 return gitcookies
167
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000168 try:
169 f = open(path, 'rb')
170 except IOError:
171 return gitcookies
172
173 with f:
174 for line in f:
175 try:
176 fields = line.strip().split('\t')
177 if line.strip().startswith('#') or len(fields) != 7:
178 continue
179 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
180 if xpath == '/' and key == 'o':
181 login, secret_token = value.split('=', 1)
182 gitcookies[domain] = (login, secret_token)
183 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100184 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000185
186 return gitcookies
187
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000188 def get_auth_header(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000189 auth = None
190 for domain, creds in self.gitcookies.iteritems():
191 if cookielib.domain_match(host, domain):
192 auth = (creds[0], None, creds[1])
193 break
194
195 if not auth:
196 auth = self.netrc.authenticators(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000197 if auth:
198 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
199 return None
200
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100201
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000202# Backwards compatibility just in case somebody imports this outside of
203# depot_tools.
204NetrcAuthenticator = CookiesAuthenticator
205
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000206
207class GceAuthenticator(Authenticator):
208 """Authenticator implementation that uses GCE metadata service for token.
209 """
210
211 _INFO_URL = 'http://metadata.google.internal'
212 _ACQUIRE_URL = ('http://metadata/computeMetadata/v1/instance/'
213 'service-accounts/default/token')
214 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
215
216 _cache_is_gce = None
217 _token_cache = None
218 _token_expiration = None
219
220 @classmethod
221 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500222 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
223 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000224 if cls._cache_is_gce is None:
225 cls._cache_is_gce = cls._test_is_gce()
226 return cls._cache_is_gce
227
228 @classmethod
229 def _test_is_gce(cls):
230 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
231 try:
232 resp = cls._get(cls._INFO_URL)
233 except socket.error:
234 # Could not resolve URL.
235 return False
236 return resp.getheader('Metadata-Flavor', None) == 'Google'
237
238 @staticmethod
239 def _get(url, **kwargs):
240 next_delay_sec = 1
241 for i in xrange(TRY_LIMIT):
242 if i > 0:
243 # Retry server error status codes.
244 LOGGER.info('Encountered server error; retrying after %d second(s).',
245 next_delay_sec)
246 time.sleep(next_delay_sec)
247 next_delay_sec *= 2
248
249 p = urlparse.urlparse(url)
250 c = GetConnectionClass(protocol=p.scheme)(p.netloc)
251 c.request('GET', url, **kwargs)
252 resp = c.getresponse()
253 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
254 if resp.status < httplib.INTERNAL_SERVER_ERROR:
255 return resp
256
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000257 @classmethod
258 def _get_token_dict(cls):
259 if cls._token_cache:
260 # If it expires within 25 seconds, refresh.
261 if cls._token_expiration < time.time() - 25:
262 return cls._token_cache
263
264 resp = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
265 if resp.status != httplib.OK:
266 return None
267 cls._token_cache = json.load(resp)
268 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
269 return cls._token_cache
270
271 def get_auth_header(self, _host):
272 token_dict = self._get_token_dict()
273 if not token_dict:
274 return None
275 return '%(token_type)s %(access_token)s' % token_dict
276
277
szager@chromium.orgb4696232013-10-16 19:45:35 +0000278def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
279 """Opens an https connection to a gerrit service, and sends a request."""
280 headers = headers or {}
281 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000282
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000283 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000284 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000285 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000286 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000287 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000288
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800289 url = path
290 if not url.startswith('/'):
291 url = '/' + url
292 if 'Authorization' in headers and not url.startswith('/a/'):
293 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000294
szager@chromium.orgb4696232013-10-16 19:45:35 +0000295 if body:
296 body = json.JSONEncoder().encode(body)
297 headers.setdefault('Content-Type', 'application/json')
298 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000299 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000300 for key, val in headers.iteritems():
301 if key == 'Authorization':
302 val = 'HIDDEN'
303 LOGGER.debug('%s: %s' % (key, val))
304 if body:
305 LOGGER.debug(body)
306 conn = GetConnectionClass()(host)
307 conn.req_host = host
308 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000309 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000310 'method': reqtype,
311 'headers': headers,
312 'body': body,
313 }
314 conn.request(**conn.req_params)
315 return conn
316
317
318def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
319 """Reads an http response from a connection into a string buffer.
320
321 Args:
322 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
323 expect_status: Success is indicated by this status in the response.
324 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
325 doesn't match the database contents. In most such cases, we
326 want the API to return None rather than raise an Exception.
327 Returns: A string buffer containing the connection's reply.
328 """
329
330 sleep_time = 0.5
331 for idx in range(TRY_LIMIT):
332 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000333
334 # Check if this is an authentication issue.
335 www_authenticate = response.getheader('www-authenticate')
336 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
337 www_authenticate):
338 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
339 host = auth_match.group(1) if auth_match else conn.req_host
340 reason = ('Authentication failed. Please make sure your .netrc file '
341 'has credentials for %s' % host)
342 raise GerritAuthenticationError(response.status, reason)
343
szager@chromium.orgb4696232013-10-16 19:45:35 +0000344 # If response.status < 500 then the result is final; break retry loop.
345 if response.status < 500:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100346 LOGGER.debug('got response %d for %s %s', response.status,
347 conn.req_params['method'], conn.req_params['url'])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000348 break
349 # A status >=500 is assumed to be a possible transient error; retry.
350 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100351 LOGGER.warn('A transient error occurred while querying %s:\n'
352 '%s %s %s\n'
353 '%s %d %s',
354 conn.host, conn.req_params['method'], conn.req_params['url'],
355 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000356 if TRY_LIMIT - idx > 1:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100357 LOGGER.warn('... will retry %d more times.', TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000358 time.sleep(sleep_time)
359 sleep_time = sleep_time * 2
360 req_host = conn.req_host
361 req_params = conn.req_params
362 conn = GetConnectionClass()(req_host)
363 conn.req_host = req_host
364 conn.req_params = req_params
365 conn.request(**req_params)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000366 if ignore_404 and response.status == 404:
367 return StringIO()
368 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000369 reason = '%s: %s' % (response.reason, response.read())
370 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000371 return StringIO(response.read())
372
373
374def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
375 """Parses an https response as json."""
376 fh = ReadHttpResponse(
377 conn, expect_status=expect_status, ignore_404=ignore_404)
378 # The first line of the response should always be: )]}'
379 s = fh.readline()
380 if s and s.rstrip() != ")]}'":
381 raise GerritError(200, 'Unexpected json output: %s' % s)
382 s = fh.read()
383 if not s:
384 return None
385 return json.loads(s)
386
387
388def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
389 sortkey=None):
390 """
391 Queries a gerrit-on-borg server for changes matching query terms.
392
393 Args:
394 param_dict: A dictionary of search parameters, as documented here:
395 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
396 first_param: A change identifier
397 limit: Maximum number of results to return.
398 o_params: A list of additional output specifiers, as documented here:
399 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
400 Returns:
401 A list of json-decoded query results.
402 """
403 # Note that no attempt is made to escape special characters; YMMV.
404 if not param_dict and not first_param:
405 raise RuntimeError('QueryChanges requires search parameters')
406 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
407 if sortkey:
408 path = '%s&N=%s' % (path, sortkey)
409 if limit:
410 path = '%s&n=%d' % (path, limit)
411 if o_params:
412 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
413 # Don't ignore 404; a query should always return a list, even if it's empty.
414 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
415
416
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000417def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
418 o_params=None, sortkey=None):
419 """
420 Queries a gerrit-on-borg server for all the changes matching the query terms.
421
422 A single query to gerrit-on-borg is limited on the number of results by the
423 limit parameter on the request (see QueryChanges) and the server maximum
424 limit. This function uses the "_more_changes" and "_sortkey" attributes on
425 the returned changes to iterate all of them making multiple queries to the
426 server, regardless the query limit.
427
428 Args:
429 param_dict, first_param: Refer to QueryChanges().
430 limit: Maximum number of requested changes per query.
431 o_params: Refer to QueryChanges().
432 sortkey: The value of the "_sortkey" attribute where starts from. None to
433 start from the first change.
434
435 Returns:
436 A generator object to the list of returned changes, possibly unbound.
437 """
438 more_changes = True
439 while more_changes:
440 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
441 for cl in page:
442 yield cl
443
444 more_changes = [cl for cl in page if '_more_changes' in cl]
445 if len(more_changes) > 1:
446 raise GerritError(
447 200,
448 'Received %d changes with a _more_changes attribute set but should '
449 'receive at most one.' % len(more_changes))
450 if more_changes:
451 sortkey = more_changes[0]['_sortkey']
452
453
szager@chromium.orgb4696232013-10-16 19:45:35 +0000454def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
455 sortkey=None):
456 """Initiate a query composed of multiple sets of query parameters."""
457 if not change_list:
458 raise RuntimeError(
459 "MultiQueryChanges requires a list of change numbers/id's")
460 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
461 if param_dict:
462 q.append(_QueryString(param_dict))
463 if limit:
464 q.append('n=%d' % limit)
465 if sortkey:
466 q.append('N=%s' % sortkey)
467 if o_params:
468 q.extend(['o=%s' % p for p in o_params])
469 path = 'changes/?%s' % '&'.join(q)
470 try:
471 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
472 except GerritError as e:
473 msg = '%s:\n%s' % (e.message, path)
474 raise GerritError(e.http_status, msg)
475 return result
476
477
478def GetGerritFetchUrl(host):
479 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
480 return '%s://%s/' % (GERRIT_PROTOCOL, host)
481
482
483def GetChangePageUrl(host, change_number):
484 """Given a gerrit host name and change number, return change page url."""
485 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
486
487
488def GetChangeUrl(host, change):
489 """Given a gerrit host name and change id, return an url for the change."""
490 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
491
492
493def GetChange(host, change):
494 """Query a gerrit server for information about a single change."""
495 path = 'changes/%s' % change
496 return ReadHttpJsonResponse(CreateHttpConn(host, path))
497
498
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100499def GetChangeDetail(host, change, o_params=None, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000500 """Query a gerrit server for extended information about a single change."""
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100501 # TODO(tandrii): cahnge ignore_404 to False by default.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000502 path = 'changes/%s/detail' % change
503 if o_params:
504 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100505 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=ignore_404)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000506
507
agable32978d92016-11-01 12:55:02 -0700508def GetChangeCommit(host, change, revision='current'):
509 """Query a gerrit server for a revision associated with a change."""
510 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
511 return ReadHttpJsonResponse(CreateHttpConn(host, path))
512
513
szager@chromium.orgb4696232013-10-16 19:45:35 +0000514def GetChangeCurrentRevision(host, change):
515 """Get information about the latest revision for a given change."""
516 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
517
518
519def GetChangeRevisions(host, change):
520 """Get information about all revisions associated with a change."""
521 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
522
523
524def GetChangeReview(host, change, revision=None):
525 """Get the current review information for a change."""
526 if not revision:
527 jmsg = GetChangeRevisions(host, change)
528 if not jmsg:
529 return None
530 elif len(jmsg) > 1:
531 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
532 revision = jmsg[0]['current_revision']
533 path = 'changes/%s/revisions/%s/review'
534 return ReadHttpJsonResponse(CreateHttpConn(host, path))
535
536
537def AbandonChange(host, change, msg=''):
538 """Abandon a gerrit change."""
539 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000540 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000541 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
542 return ReadHttpJsonResponse(conn, ignore_404=False)
543
544
545def RestoreChange(host, change, msg=''):
546 """Restore a previously abandoned change."""
547 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000548 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000549 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
550 return ReadHttpJsonResponse(conn, ignore_404=False)
551
552
553def SubmitChange(host, change, wait_for_merge=True):
554 """Submits a gerrit change via Gerrit."""
555 path = 'changes/%s/submit' % change
556 body = {'wait_for_merge': wait_for_merge}
557 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
558 return ReadHttpJsonResponse(conn, ignore_404=False)
559
560
dsansomee2d6fd92016-09-08 00:10:47 -0700561def HasPendingChangeEdit(host, change):
562 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
563 try:
564 ReadHttpResponse(conn, ignore_404=False)
565 except GerritError as e:
566 # On success, gerrit returns status 204; anything else is an error.
567 if e.http_status != 204:
568 raise
569 return False
570 else:
571 return True
572
573
574def DeletePendingChangeEdit(host, change):
575 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
576 try:
577 ReadHttpResponse(conn, ignore_404=False)
578 except GerritError as e:
579 # On success, gerrit returns status 204; if the edit was already deleted it
580 # returns 404. Anything else is an error.
581 if e.http_status not in (204, 404):
582 raise
583
584
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100585def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000586 """Updates a commit message."""
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000587 try:
Aaron Gablee9373d62016-12-13 12:28:45 -0800588 assert notify in ('ALL', 'NONE')
589 # First, edit the commit message in a draft.
590 path = 'changes/%s/edit:message' % change
591 body = {'message': description}
592 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
593 try:
594 ReadHttpResponse(conn, ignore_404=False)
595 except GerritError as e:
596 # On success, gerrit returns status 204; anything else is an error.
597 if e.http_status != 204:
598 raise
599 else:
600 raise GerritError(
601 'Unexpectedly received a 200 http status while editing message in '
602 'change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000603
Aaron Gablee9373d62016-12-13 12:28:45 -0800604 # And then publish it.
605 path = 'changes/%s/edit:publish' % change
606 conn = CreateHttpConn(host, path, reqtype='POST', body={'notify': notify})
607 try:
608 ReadHttpResponse(conn, ignore_404=False)
609 except GerritError as e:
610 # On success, gerrit returns status 204; anything else is an error.
611 if e.http_status != 204:
612 raise
613 else:
614 raise GerritError(
615 'Unexpectedly received a 200 http status while publishing message '
616 'change in %s' % change)
617 except (GerritError, KeyboardInterrupt) as e:
618 # Something went wrong with one of the two calls, so we want to clean up
619 # after ourselves before returning.
620 try:
621 DeletePendingChangeEdit(host, change)
622 except GerritError:
623 LOGGER.error('Encountered error while cleaning up after failed attempt '
624 'to set the CL description. You may have to delete the '
625 'pending change edit yourself in the web UI.')
626 raise e
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000627
628
szager@chromium.orgb4696232013-10-16 19:45:35 +0000629def GetReviewers(host, change):
630 """Get information about all reviewers attached to a change."""
631 path = 'changes/%s/reviewers' % change
632 return ReadHttpJsonResponse(CreateHttpConn(host, path))
633
634
635def GetReview(host, change, revision):
636 """Get review information about a specific revision of a change."""
637 path = 'changes/%s/revisions/%s/review' % (change, revision)
638 return ReadHttpJsonResponse(CreateHttpConn(host, path))
639
640
Aaron Gable59f48512017-01-12 10:54:46 -0800641def AddReviewers(host, change, add=None, is_reviewer=True, notify=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000642 """Add reviewers to a change."""
Aaron Gabledf86e302016-11-08 10:48:03 -0800643 errors = None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000644 if not add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800645 return None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000646 if isinstance(add, basestring):
647 add = (add,)
648 path = 'changes/%s/reviewers' % change
649 for r in add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800650 state = 'REVIEWER' if is_reviewer else 'CC'
Aaron Gable59f48512017-01-12 10:54:46 -0800651 notify = 'ALL' if notify else 'NONE'
tandrii88189772016-09-29 04:29:57 -0700652 body = {
653 'reviewer': r,
Aaron Gabledf86e302016-11-08 10:48:03 -0800654 'state': state,
Aaron Gable59f48512017-01-12 10:54:46 -0800655 'notify': notify,
tandrii88189772016-09-29 04:29:57 -0700656 }
Aaron Gabledf86e302016-11-08 10:48:03 -0800657 try:
658 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
659 _ = ReadHttpJsonResponse(conn, ignore_404=False)
660 except GerritError as e:
661 if e.http_status == 422: # "Unprocessable Entity"
662 LOGGER.warn('Failed to add "%s" as a %s' % (r, state.lower()))
663 errors = True
664 else:
665 raise
666 return errors
szager@chromium.orgb4696232013-10-16 19:45:35 +0000667
668
669def RemoveReviewers(host, change, remove=None):
670 """Remove reveiewers from a change."""
671 if not remove:
672 return
673 if isinstance(remove, basestring):
674 remove = (remove,)
675 for r in remove:
676 path = 'changes/%s/reviewers/%s' % (change, r)
677 conn = CreateHttpConn(host, path, reqtype='DELETE')
678 try:
679 ReadHttpResponse(conn, ignore_404=False)
680 except GerritError as e:
681 # On success, gerrit returns status 204; anything else is an error.
682 if e.http_status != 204:
683 raise
684 else:
685 raise GerritError(
686 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
687 ' from change %s' % (r, change))
688
689
690def SetReview(host, change, msg=None, labels=None, notify=None):
691 """Set labels and/or add a message to a code review."""
692 if not msg and not labels:
693 return
694 path = 'changes/%s/revisions/current/review' % change
695 body = {}
696 if msg:
697 body['message'] = msg
698 if labels:
699 body['labels'] = labels
700 if notify:
701 body['notify'] = notify
702 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
703 response = ReadHttpJsonResponse(conn)
704 if labels:
705 for key, val in labels.iteritems():
706 if ('labels' not in response or key not in response['labels'] or
707 int(response['labels'][key] != int(val))):
708 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
709 key, change))
710
711
712def ResetReviewLabels(host, change, label, value='0', message=None,
713 notify=None):
714 """Reset the value of a given label for all reviewers on a change."""
715 # This is tricky, because we want to work on the "current revision", but
716 # there's always the risk that "current revision" will change in between
717 # API calls. So, we check "current revision" at the beginning and end; if
718 # it has changed, raise an exception.
719 jmsg = GetChangeCurrentRevision(host, change)
720 if not jmsg:
721 raise GerritError(
722 200, 'Could not get review information for change "%s"' % change)
723 value = str(value)
724 revision = jmsg[0]['current_revision']
725 path = 'changes/%s/revisions/%s/review' % (change, revision)
726 message = message or (
727 '%s label set to %s programmatically.' % (label, value))
728 jmsg = GetReview(host, change, revision)
729 if not jmsg:
730 raise GerritError(200, 'Could not get review information for revison %s '
731 'of change %s' % (revision, change))
732 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
733 if str(review.get('value', value)) != value:
734 body = {
735 'message': message,
736 'labels': {label: value},
737 'on_behalf_of': review['_account_id'],
738 }
739 if notify:
740 body['notify'] = notify
741 conn = CreateHttpConn(
742 host, path, reqtype='POST', body=body)
743 response = ReadHttpJsonResponse(conn)
744 if str(response['labels'][label]) != value:
745 username = review.get('email', jmsg.get('name', ''))
746 raise GerritError(200, 'Unable to set %s label for user "%s"'
747 ' on change %s.' % (label, username, change))
748 jmsg = GetChangeCurrentRevision(host, change)
749 if not jmsg:
750 raise GerritError(
751 200, 'Could not get review information for change "%s"' % change)
752 elif jmsg[0]['current_revision'] != revision:
753 raise GerritError(200, 'While resetting labels on change "%s", '
754 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800755
756
dimu833c94c2017-01-18 17:36:15 -0800757def CreateGerritBranch(host, project, branch, commit):
758 """
759 Create a new branch from given project and commit
760 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
761
762 Returns:
763 A JSON with 'ref' key
764 """
765 path = 'projects/%s/branches/%s' % (project, branch)
766 body = {'revision': commit}
767 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
768 response = ReadHttpJsonResponse(conn)
769 if response:
770 return response
771 raise GerritError(200, 'Unable to create gerrit branch')
772
773
774def GetGerritBranch(host, project, branch):
775 """
776 Get a branch from given project and commit
777 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
778
779 Returns:
780 A JSON object with 'revision' key
781 """
782 path = 'projects/%s/branches/%s' % (project, branch)
783 conn = CreateHttpConn(host, path, reqtype='GET')
784 response = ReadHttpJsonResponse(conn)
785 if response:
786 return response
787 raise GerritError(200, 'Unable to get gerrit branch')
788
789
Dan Jacques8d11e482016-11-15 14:25:56 -0800790@contextlib.contextmanager
791def tempdir():
792 tdir = None
793 try:
794 tdir = tempfile.mkdtemp(suffix='gerrit_util')
795 yield tdir
796 finally:
797 if tdir:
798 gclient_utils.rmtree(tdir)