blob: 0ca9036f6f7f0c54594ea8696a16fc7b0a95e51c [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):
159 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:
184 logging.warning(exc)
185
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
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000201# Backwards compatibility just in case somebody imports this outside of
202# depot_tools.
203NetrcAuthenticator = CookiesAuthenticator
204
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000205
206class GceAuthenticator(Authenticator):
207 """Authenticator implementation that uses GCE metadata service for token.
208 """
209
210 _INFO_URL = 'http://metadata.google.internal'
211 _ACQUIRE_URL = ('http://metadata/computeMetadata/v1/instance/'
212 'service-accounts/default/token')
213 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
214
215 _cache_is_gce = None
216 _token_cache = None
217 _token_expiration = None
218
219 @classmethod
220 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500221 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
222 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000223 if cls._cache_is_gce is None:
224 cls._cache_is_gce = cls._test_is_gce()
225 return cls._cache_is_gce
226
227 @classmethod
228 def _test_is_gce(cls):
229 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
230 try:
231 resp = cls._get(cls._INFO_URL)
232 except socket.error:
233 # Could not resolve URL.
234 return False
235 return resp.getheader('Metadata-Flavor', None) == 'Google'
236
237 @staticmethod
238 def _get(url, **kwargs):
239 next_delay_sec = 1
240 for i in xrange(TRY_LIMIT):
241 if i > 0:
242 # Retry server error status codes.
243 LOGGER.info('Encountered server error; retrying after %d second(s).',
244 next_delay_sec)
245 time.sleep(next_delay_sec)
246 next_delay_sec *= 2
247
248 p = urlparse.urlparse(url)
249 c = GetConnectionClass(protocol=p.scheme)(p.netloc)
250 c.request('GET', url, **kwargs)
251 resp = c.getresponse()
252 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
253 if resp.status < httplib.INTERNAL_SERVER_ERROR:
254 return resp
255
256
257 @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
278
szager@chromium.orgb4696232013-10-16 19:45:35 +0000279def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
280 """Opens an https connection to a gerrit service, and sends a request."""
281 headers = headers or {}
282 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000283
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000284 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000285 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000286 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000287 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000288 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000289
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800290 url = path
291 if not url.startswith('/'):
292 url = '/' + url
293 if 'Authorization' in headers and not url.startswith('/a/'):
294 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000295
szager@chromium.orgb4696232013-10-16 19:45:35 +0000296 if body:
297 body = json.JSONEncoder().encode(body)
298 headers.setdefault('Content-Type', 'application/json')
299 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000300 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000301 for key, val in headers.iteritems():
302 if key == 'Authorization':
303 val = 'HIDDEN'
304 LOGGER.debug('%s: %s' % (key, val))
305 if body:
306 LOGGER.debug(body)
307 conn = GetConnectionClass()(host)
308 conn.req_host = host
309 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000310 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000311 'method': reqtype,
312 'headers': headers,
313 'body': body,
314 }
315 conn.request(**conn.req_params)
316 return conn
317
318
319def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
320 """Reads an http response from a connection into a string buffer.
321
322 Args:
323 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
324 expect_status: Success is indicated by this status in the response.
325 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
326 doesn't match the database contents. In most such cases, we
327 want the API to return None rather than raise an Exception.
328 Returns: A string buffer containing the connection's reply.
329 """
330
331 sleep_time = 0.5
332 for idx in range(TRY_LIMIT):
333 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000334
335 # Check if this is an authentication issue.
336 www_authenticate = response.getheader('www-authenticate')
337 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
338 www_authenticate):
339 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
340 host = auth_match.group(1) if auth_match else conn.req_host
341 reason = ('Authentication failed. Please make sure your .netrc file '
342 'has credentials for %s' % host)
343 raise GerritAuthenticationError(response.status, reason)
344
szager@chromium.orgb4696232013-10-16 19:45:35 +0000345 # If response.status < 500 then the result is final; break retry loop.
346 if response.status < 500:
347 break
348 # A status >=500 is assumed to be a possible transient error; retry.
349 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
350 msg = (
qyearsley12fa6ff2016-08-24 09:18:40 -0700351 'A transient error occurred while querying %s:\n'
szager@chromium.orgb4696232013-10-16 19:45:35 +0000352 '%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))
356 if TRY_LIMIT - idx > 1:
357 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
358 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)
366 LOGGER.warn(msg)
367 if ignore_404 and response.status == 404:
368 return StringIO()
369 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000370 reason = '%s: %s' % (response.reason, response.read())
371 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000372 return StringIO(response.read())
373
374
375def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
376 """Parses an https response as json."""
377 fh = ReadHttpResponse(
378 conn, expect_status=expect_status, ignore_404=ignore_404)
379 # The first line of the response should always be: )]}'
380 s = fh.readline()
381 if s and s.rstrip() != ")]}'":
382 raise GerritError(200, 'Unexpected json output: %s' % s)
383 s = fh.read()
384 if not s:
385 return None
386 return json.loads(s)
387
388
389def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
390 sortkey=None):
391 """
392 Queries a gerrit-on-borg server for changes matching query terms.
393
394 Args:
395 param_dict: A dictionary of search parameters, as documented here:
396 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
397 first_param: A change identifier
398 limit: Maximum number of results to return.
399 o_params: A list of additional output specifiers, as documented here:
400 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
401 Returns:
402 A list of json-decoded query results.
403 """
404 # Note that no attempt is made to escape special characters; YMMV.
405 if not param_dict and not first_param:
406 raise RuntimeError('QueryChanges requires search parameters')
407 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
408 if sortkey:
409 path = '%s&N=%s' % (path, sortkey)
410 if limit:
411 path = '%s&n=%d' % (path, limit)
412 if o_params:
413 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
414 # Don't ignore 404; a query should always return a list, even if it's empty.
415 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
416
417
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000418def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
419 o_params=None, sortkey=None):
420 """
421 Queries a gerrit-on-borg server for all the changes matching the query terms.
422
423 A single query to gerrit-on-borg is limited on the number of results by the
424 limit parameter on the request (see QueryChanges) and the server maximum
425 limit. This function uses the "_more_changes" and "_sortkey" attributes on
426 the returned changes to iterate all of them making multiple queries to the
427 server, regardless the query limit.
428
429 Args:
430 param_dict, first_param: Refer to QueryChanges().
431 limit: Maximum number of requested changes per query.
432 o_params: Refer to QueryChanges().
433 sortkey: The value of the "_sortkey" attribute where starts from. None to
434 start from the first change.
435
436 Returns:
437 A generator object to the list of returned changes, possibly unbound.
438 """
439 more_changes = True
440 while more_changes:
441 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
442 for cl in page:
443 yield cl
444
445 more_changes = [cl for cl in page if '_more_changes' in cl]
446 if len(more_changes) > 1:
447 raise GerritError(
448 200,
449 'Received %d changes with a _more_changes attribute set but should '
450 'receive at most one.' % len(more_changes))
451 if more_changes:
452 sortkey = more_changes[0]['_sortkey']
453
454
szager@chromium.orgb4696232013-10-16 19:45:35 +0000455def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
456 sortkey=None):
457 """Initiate a query composed of multiple sets of query parameters."""
458 if not change_list:
459 raise RuntimeError(
460 "MultiQueryChanges requires a list of change numbers/id's")
461 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
462 if param_dict:
463 q.append(_QueryString(param_dict))
464 if limit:
465 q.append('n=%d' % limit)
466 if sortkey:
467 q.append('N=%s' % sortkey)
468 if o_params:
469 q.extend(['o=%s' % p for p in o_params])
470 path = 'changes/?%s' % '&'.join(q)
471 try:
472 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
473 except GerritError as e:
474 msg = '%s:\n%s' % (e.message, path)
475 raise GerritError(e.http_status, msg)
476 return result
477
478
479def GetGerritFetchUrl(host):
480 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
481 return '%s://%s/' % (GERRIT_PROTOCOL, host)
482
483
484def GetChangePageUrl(host, change_number):
485 """Given a gerrit host name and change number, return change page url."""
486 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
487
488
489def GetChangeUrl(host, change):
490 """Given a gerrit host name and change id, return an url for the change."""
491 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
492
493
494def GetChange(host, change):
495 """Query a gerrit server for information about a single change."""
496 path = 'changes/%s' % change
497 return ReadHttpJsonResponse(CreateHttpConn(host, path))
498
499
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100500def GetChangeDetail(host, change, o_params=None, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000501 """Query a gerrit server for extended information about a single change."""
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100502 # TODO(tandrii): cahnge ignore_404 to False by default.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000503 path = 'changes/%s/detail' % change
504 if o_params:
505 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100506 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=ignore_404)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000507
508
agable32978d92016-11-01 12:55:02 -0700509def GetChangeCommit(host, change, revision='current'):
510 """Query a gerrit server for a revision associated with a change."""
511 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
512 return ReadHttpJsonResponse(CreateHttpConn(host, path))
513
514
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000515def GetChangeDescriptionFromGitiles(url, revision):
516 """Query Gitiles for actual commit message for a given url and ref.
517
518 url must be obtained from call to GetChangeDetail for a specific
519 revision (patchset) under 'fetch' key.
520 """
521 parsed = urlparse.urlparse(url)
522 path = '%s/+/%s?format=json' % (parsed.path, revision)
tandrii@chromium.orgc767e3f2016-04-26 14:28:49 +0000523 # Note: Gerrit instances that Chrome infrastructure uses thus far have all
524 # enabled Gitiles, which allowes us to execute this call. This isn't true for
525 # all Gerrit instances out there. Thus, if line below fails, consider adding a
526 # fallback onto actually fetching ref from remote using pure git.
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000527 return ReadHttpJsonResponse(CreateHttpConn(parsed.netloc, path))['message']
528
529
szager@chromium.orgb4696232013-10-16 19:45:35 +0000530def GetChangeCurrentRevision(host, change):
531 """Get information about the latest revision for a given change."""
532 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
533
534
535def GetChangeRevisions(host, change):
536 """Get information about all revisions associated with a change."""
537 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
538
539
540def GetChangeReview(host, change, revision=None):
541 """Get the current review information for a change."""
542 if not revision:
543 jmsg = GetChangeRevisions(host, change)
544 if not jmsg:
545 return None
546 elif len(jmsg) > 1:
547 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
548 revision = jmsg[0]['current_revision']
549 path = 'changes/%s/revisions/%s/review'
550 return ReadHttpJsonResponse(CreateHttpConn(host, path))
551
552
553def AbandonChange(host, change, msg=''):
554 """Abandon a gerrit change."""
555 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000556 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000557 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
558 return ReadHttpJsonResponse(conn, ignore_404=False)
559
560
561def RestoreChange(host, change, msg=''):
562 """Restore a previously abandoned change."""
563 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000564 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000565 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
566 return ReadHttpJsonResponse(conn, ignore_404=False)
567
568
569def SubmitChange(host, change, wait_for_merge=True):
570 """Submits a gerrit change via Gerrit."""
571 path = 'changes/%s/submit' % change
572 body = {'wait_for_merge': wait_for_merge}
573 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
574 return ReadHttpJsonResponse(conn, ignore_404=False)
575
576
dsansomee2d6fd92016-09-08 00:10:47 -0700577def HasPendingChangeEdit(host, change):
578 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
579 try:
580 ReadHttpResponse(conn, ignore_404=False)
581 except GerritError as e:
582 # On success, gerrit returns status 204; anything else is an error.
583 if e.http_status != 204:
584 raise
585 return False
586 else:
587 return True
588
589
590def DeletePendingChangeEdit(host, change):
591 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
592 try:
593 ReadHttpResponse(conn, ignore_404=False)
594 except GerritError as e:
595 # On success, gerrit returns status 204; if the edit was already deleted it
596 # returns 404. Anything else is an error.
597 if e.http_status not in (204, 404):
598 raise
599
600
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000601def SetCommitMessage(host, change, description):
602 """Updates a commit message."""
603 # First, edit the commit message in a draft.
604 path = 'changes/%s/edit:message' % change
605 body = {'message': description}
606 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
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 editing message in '
616 'change %s' % change)
617
618 # And then publish it.
619 path = 'changes/%s/edit:publish' % change
620 conn = CreateHttpConn(host, path, reqtype='POST', body={})
621 try:
622 ReadHttpResponse(conn, ignore_404=False)
623 except GerritError as e:
624 # On success, gerrit returns status 204; anything else is an error.
625 if e.http_status != 204:
626 raise
627 else:
628 raise GerritError(
629 'Unexpectedly received a 200 http status while publishing message '
630 'change in %s' % change)
631
632
szager@chromium.orgb4696232013-10-16 19:45:35 +0000633def GetReviewers(host, change):
634 """Get information about all reviewers attached to a change."""
635 path = 'changes/%s/reviewers' % change
636 return ReadHttpJsonResponse(CreateHttpConn(host, path))
637
638
639def GetReview(host, change, revision):
640 """Get review information about a specific revision of a change."""
641 path = 'changes/%s/revisions/%s/review' % (change, revision)
642 return ReadHttpJsonResponse(CreateHttpConn(host, path))
643
644
tandrii88189772016-09-29 04:29:57 -0700645def AddReviewers(host, change, add=None, is_reviewer=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000646 """Add reviewers to a change."""
Aaron Gabledf86e302016-11-08 10:48:03 -0800647 errors = None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000648 if not add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800649 return None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000650 if isinstance(add, basestring):
651 add = (add,)
652 path = 'changes/%s/reviewers' % change
653 for r in add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800654 state = 'REVIEWER' if is_reviewer else 'CC'
tandrii88189772016-09-29 04:29:57 -0700655 body = {
656 'reviewer': r,
Aaron Gabledf86e302016-11-08 10:48:03 -0800657 'state': state,
tandrii88189772016-09-29 04:29:57 -0700658 }
Aaron Gabledf86e302016-11-08 10:48:03 -0800659 try:
660 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
661 _ = ReadHttpJsonResponse(conn, ignore_404=False)
662 except GerritError as e:
663 if e.http_status == 422: # "Unprocessable Entity"
664 LOGGER.warn('Failed to add "%s" as a %s' % (r, state.lower()))
665 errors = True
666 else:
667 raise
668 return errors
szager@chromium.orgb4696232013-10-16 19:45:35 +0000669
670
671def RemoveReviewers(host, change, remove=None):
672 """Remove reveiewers from a change."""
673 if not remove:
674 return
675 if isinstance(remove, basestring):
676 remove = (remove,)
677 for r in remove:
678 path = 'changes/%s/reviewers/%s' % (change, r)
679 conn = CreateHttpConn(host, path, reqtype='DELETE')
680 try:
681 ReadHttpResponse(conn, ignore_404=False)
682 except GerritError as e:
683 # On success, gerrit returns status 204; anything else is an error.
684 if e.http_status != 204:
685 raise
686 else:
687 raise GerritError(
688 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
689 ' from change %s' % (r, change))
690
691
692def SetReview(host, change, msg=None, labels=None, notify=None):
693 """Set labels and/or add a message to a code review."""
694 if not msg and not labels:
695 return
696 path = 'changes/%s/revisions/current/review' % change
697 body = {}
698 if msg:
699 body['message'] = msg
700 if labels:
701 body['labels'] = labels
702 if notify:
703 body['notify'] = notify
704 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
705 response = ReadHttpJsonResponse(conn)
706 if labels:
707 for key, val in labels.iteritems():
708 if ('labels' not in response or key not in response['labels'] or
709 int(response['labels'][key] != int(val))):
710 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
711 key, change))
712
713
714def ResetReviewLabels(host, change, label, value='0', message=None,
715 notify=None):
716 """Reset the value of a given label for all reviewers on a change."""
717 # This is tricky, because we want to work on the "current revision", but
718 # there's always the risk that "current revision" will change in between
719 # API calls. So, we check "current revision" at the beginning and end; if
720 # it has changed, raise an exception.
721 jmsg = GetChangeCurrentRevision(host, change)
722 if not jmsg:
723 raise GerritError(
724 200, 'Could not get review information for change "%s"' % change)
725 value = str(value)
726 revision = jmsg[0]['current_revision']
727 path = 'changes/%s/revisions/%s/review' % (change, revision)
728 message = message or (
729 '%s label set to %s programmatically.' % (label, value))
730 jmsg = GetReview(host, change, revision)
731 if not jmsg:
732 raise GerritError(200, 'Could not get review information for revison %s '
733 'of change %s' % (revision, change))
734 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
735 if str(review.get('value', value)) != value:
736 body = {
737 'message': message,
738 'labels': {label: value},
739 'on_behalf_of': review['_account_id'],
740 }
741 if notify:
742 body['notify'] = notify
743 conn = CreateHttpConn(
744 host, path, reqtype='POST', body=body)
745 response = ReadHttpJsonResponse(conn)
746 if str(response['labels'][label]) != value:
747 username = review.get('email', jmsg.get('name', ''))
748 raise GerritError(200, 'Unable to set %s label for user "%s"'
749 ' on change %s.' % (label, username, change))
750 jmsg = GetChangeCurrentRevision(host, change)
751 if not jmsg:
752 raise GerritError(
753 200, 'Could not get review information for change "%s"' % change)
754 elif jmsg[0]['current_revision'] != revision:
755 raise GerritError(200, 'While resetting labels on change "%s", '
756 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800757
758
759@contextlib.contextmanager
760def tempdir():
761 tdir = None
762 try:
763 tdir = tempfile.mkdtemp(suffix='gerrit_util')
764 yield tdir
765 finally:
766 if tdir:
767 gclient_utils.rmtree(tdir)