blob: ef7edc11d6b285719befae6f384b4a4d6c75e111 [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:
186 logging.warning(exc)
187
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:
349 break
350 # A status >=500 is assumed to be a possible transient error; retry.
351 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
352 msg = (
qyearsley12fa6ff2016-08-24 09:18:40 -0700353 'A transient error occurred while querying %s:\n'
szager@chromium.orgb4696232013-10-16 19:45:35 +0000354 '%s %s %s\n'
355 '%s %d %s' % (
356 conn.host, conn.req_params['method'], conn.req_params['url'],
357 http_version, http_version, response.status, response.reason))
358 if TRY_LIMIT - idx > 1:
359 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
360 time.sleep(sleep_time)
361 sleep_time = sleep_time * 2
362 req_host = conn.req_host
363 req_params = conn.req_params
364 conn = GetConnectionClass()(req_host)
365 conn.req_host = req_host
366 conn.req_params = req_params
367 conn.request(**req_params)
368 LOGGER.warn(msg)
369 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
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000517def GetChangeDescriptionFromGitiles(url, revision):
518 """Query Gitiles for actual commit message for a given url and ref.
519
520 url must be obtained from call to GetChangeDetail for a specific
521 revision (patchset) under 'fetch' key.
522 """
523 parsed = urlparse.urlparse(url)
524 path = '%s/+/%s?format=json' % (parsed.path, revision)
tandrii@chromium.orgc767e3f2016-04-26 14:28:49 +0000525 # Note: Gerrit instances that Chrome infrastructure uses thus far have all
526 # enabled Gitiles, which allowes us to execute this call. This isn't true for
527 # all Gerrit instances out there. Thus, if line below fails, consider adding a
528 # fallback onto actually fetching ref from remote using pure git.
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000529 return ReadHttpJsonResponse(CreateHttpConn(parsed.netloc, path))['message']
530
531
szager@chromium.orgb4696232013-10-16 19:45:35 +0000532def GetChangeCurrentRevision(host, change):
533 """Get information about the latest revision for a given change."""
534 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
535
536
537def GetChangeRevisions(host, change):
538 """Get information about all revisions associated with a change."""
539 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
540
541
542def GetChangeReview(host, change, revision=None):
543 """Get the current review information for a change."""
544 if not revision:
545 jmsg = GetChangeRevisions(host, change)
546 if not jmsg:
547 return None
548 elif len(jmsg) > 1:
549 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
550 revision = jmsg[0]['current_revision']
551 path = 'changes/%s/revisions/%s/review'
552 return ReadHttpJsonResponse(CreateHttpConn(host, path))
553
554
555def AbandonChange(host, change, msg=''):
556 """Abandon a gerrit change."""
557 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000558 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000559 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
560 return ReadHttpJsonResponse(conn, ignore_404=False)
561
562
563def RestoreChange(host, change, msg=''):
564 """Restore a previously abandoned change."""
565 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000566 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000567 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
568 return ReadHttpJsonResponse(conn, ignore_404=False)
569
570
571def SubmitChange(host, change, wait_for_merge=True):
572 """Submits a gerrit change via Gerrit."""
573 path = 'changes/%s/submit' % change
574 body = {'wait_for_merge': wait_for_merge}
575 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
576 return ReadHttpJsonResponse(conn, ignore_404=False)
577
578
dsansomee2d6fd92016-09-08 00:10:47 -0700579def HasPendingChangeEdit(host, change):
580 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
581 try:
582 ReadHttpResponse(conn, ignore_404=False)
583 except GerritError as e:
584 # On success, gerrit returns status 204; anything else is an error.
585 if e.http_status != 204:
586 raise
587 return False
588 else:
589 return True
590
591
592def DeletePendingChangeEdit(host, change):
593 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
594 try:
595 ReadHttpResponse(conn, ignore_404=False)
596 except GerritError as e:
597 # On success, gerrit returns status 204; if the edit was already deleted it
598 # returns 404. Anything else is an error.
599 if e.http_status not in (204, 404):
600 raise
601
602
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100603def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000604 """Updates a commit message."""
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000605 try:
Aaron Gablee9373d62016-12-13 12:28:45 -0800606 assert notify in ('ALL', 'NONE')
607 # First, edit the commit message in a draft.
608 path = 'changes/%s/edit:message' % change
609 body = {'message': description}
610 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
611 try:
612 ReadHttpResponse(conn, ignore_404=False)
613 except GerritError as e:
614 # On success, gerrit returns status 204; anything else is an error.
615 if e.http_status != 204:
616 raise
617 else:
618 raise GerritError(
619 'Unexpectedly received a 200 http status while editing message in '
620 'change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000621
Aaron Gablee9373d62016-12-13 12:28:45 -0800622 # And then publish it.
623 path = 'changes/%s/edit:publish' % change
624 conn = CreateHttpConn(host, path, reqtype='POST', body={'notify': notify})
625 try:
626 ReadHttpResponse(conn, ignore_404=False)
627 except GerritError as e:
628 # On success, gerrit returns status 204; anything else is an error.
629 if e.http_status != 204:
630 raise
631 else:
632 raise GerritError(
633 'Unexpectedly received a 200 http status while publishing message '
634 'change in %s' % change)
635 except (GerritError, KeyboardInterrupt) as e:
636 # Something went wrong with one of the two calls, so we want to clean up
637 # after ourselves before returning.
638 try:
639 DeletePendingChangeEdit(host, change)
640 except GerritError:
641 LOGGER.error('Encountered error while cleaning up after failed attempt '
642 'to set the CL description. You may have to delete the '
643 'pending change edit yourself in the web UI.')
644 raise e
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000645
646
szager@chromium.orgb4696232013-10-16 19:45:35 +0000647def GetReviewers(host, change):
648 """Get information about all reviewers attached to a change."""
649 path = 'changes/%s/reviewers' % change
650 return ReadHttpJsonResponse(CreateHttpConn(host, path))
651
652
653def GetReview(host, change, revision):
654 """Get review information about a specific revision of a change."""
655 path = 'changes/%s/revisions/%s/review' % (change, revision)
656 return ReadHttpJsonResponse(CreateHttpConn(host, path))
657
658
Aaron Gable59f48512017-01-12 10:54:46 -0800659def AddReviewers(host, change, add=None, is_reviewer=True, notify=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000660 """Add reviewers to a change."""
Aaron Gabledf86e302016-11-08 10:48:03 -0800661 errors = None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000662 if not add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800663 return None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664 if isinstance(add, basestring):
665 add = (add,)
666 path = 'changes/%s/reviewers' % change
667 for r in add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800668 state = 'REVIEWER' if is_reviewer else 'CC'
Aaron Gable59f48512017-01-12 10:54:46 -0800669 notify = 'ALL' if notify else 'NONE'
tandrii88189772016-09-29 04:29:57 -0700670 body = {
671 'reviewer': r,
Aaron Gabledf86e302016-11-08 10:48:03 -0800672 'state': state,
Aaron Gable59f48512017-01-12 10:54:46 -0800673 'notify': notify,
tandrii88189772016-09-29 04:29:57 -0700674 }
Aaron Gabledf86e302016-11-08 10:48:03 -0800675 try:
676 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
677 _ = ReadHttpJsonResponse(conn, ignore_404=False)
678 except GerritError as e:
679 if e.http_status == 422: # "Unprocessable Entity"
680 LOGGER.warn('Failed to add "%s" as a %s' % (r, state.lower()))
681 errors = True
682 else:
683 raise
684 return errors
szager@chromium.orgb4696232013-10-16 19:45:35 +0000685
686
687def RemoveReviewers(host, change, remove=None):
688 """Remove reveiewers from a change."""
689 if not remove:
690 return
691 if isinstance(remove, basestring):
692 remove = (remove,)
693 for r in remove:
694 path = 'changes/%s/reviewers/%s' % (change, r)
695 conn = CreateHttpConn(host, path, reqtype='DELETE')
696 try:
697 ReadHttpResponse(conn, ignore_404=False)
698 except GerritError as e:
699 # On success, gerrit returns status 204; anything else is an error.
700 if e.http_status != 204:
701 raise
702 else:
703 raise GerritError(
704 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
705 ' from change %s' % (r, change))
706
707
708def SetReview(host, change, msg=None, labels=None, notify=None):
709 """Set labels and/or add a message to a code review."""
710 if not msg and not labels:
711 return
712 path = 'changes/%s/revisions/current/review' % change
713 body = {}
714 if msg:
715 body['message'] = msg
716 if labels:
717 body['labels'] = labels
718 if notify:
719 body['notify'] = notify
720 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
721 response = ReadHttpJsonResponse(conn)
722 if labels:
723 for key, val in labels.iteritems():
724 if ('labels' not in response or key not in response['labels'] or
725 int(response['labels'][key] != int(val))):
726 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
727 key, change))
728
729
730def ResetReviewLabels(host, change, label, value='0', message=None,
731 notify=None):
732 """Reset the value of a given label for all reviewers on a change."""
733 # This is tricky, because we want to work on the "current revision", but
734 # there's always the risk that "current revision" will change in between
735 # API calls. So, we check "current revision" at the beginning and end; if
736 # it has changed, raise an exception.
737 jmsg = GetChangeCurrentRevision(host, change)
738 if not jmsg:
739 raise GerritError(
740 200, 'Could not get review information for change "%s"' % change)
741 value = str(value)
742 revision = jmsg[0]['current_revision']
743 path = 'changes/%s/revisions/%s/review' % (change, revision)
744 message = message or (
745 '%s label set to %s programmatically.' % (label, value))
746 jmsg = GetReview(host, change, revision)
747 if not jmsg:
748 raise GerritError(200, 'Could not get review information for revison %s '
749 'of change %s' % (revision, change))
750 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
751 if str(review.get('value', value)) != value:
752 body = {
753 'message': message,
754 'labels': {label: value},
755 'on_behalf_of': review['_account_id'],
756 }
757 if notify:
758 body['notify'] = notify
759 conn = CreateHttpConn(
760 host, path, reqtype='POST', body=body)
761 response = ReadHttpJsonResponse(conn)
762 if str(response['labels'][label]) != value:
763 username = review.get('email', jmsg.get('name', ''))
764 raise GerritError(200, 'Unable to set %s label for user "%s"'
765 ' on change %s.' % (label, username, change))
766 jmsg = GetChangeCurrentRevision(host, change)
767 if not jmsg:
768 raise GerritError(
769 200, 'Could not get review information for change "%s"' % change)
770 elif jmsg[0]['current_revision'] != revision:
771 raise GerritError(200, 'While resetting labels on change "%s", '
772 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800773
774
dimu833c94c2017-01-18 17:36:15 -0800775def CreateGerritBranch(host, project, branch, commit):
776 """
777 Create a new branch from given project and commit
778 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
779
780 Returns:
781 A JSON with 'ref' key
782 """
783 path = 'projects/%s/branches/%s' % (project, branch)
784 body = {'revision': commit}
785 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
786 response = ReadHttpJsonResponse(conn)
787 if response:
788 return response
789 raise GerritError(200, 'Unable to create gerrit branch')
790
791
792def GetGerritBranch(host, project, branch):
793 """
794 Get a branch from given project and commit
795 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
796
797 Returns:
798 A JSON object with 'revision' key
799 """
800 path = 'projects/%s/branches/%s' % (project, branch)
801 conn = CreateHttpConn(host, path, reqtype='GET')
802 response = ReadHttpJsonResponse(conn)
803 if response:
804 return response
805 raise GerritError(200, 'Unable to get gerrit branch')
806
807
Dan Jacques8d11e482016-11-15 14:25:56 -0800808@contextlib.contextmanager
809def tempdir():
810 tdir = None
811 try:
812 tdir = tempfile.mkdtemp(suffix='gerrit_util')
813 yield tdir
814 finally:
815 if tdir:
816 gclient_utils.rmtree(tdir)