blob: ff3785819ddca0fff52c2fe2b1e2fc1bbd9cb50e [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
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000012import cookielib
szager@chromium.orgb4696232013-10-16 19:45:35 +000013import httplib
14import json
15import logging
16import netrc
17import os
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000018import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000019import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000020import stat
21import sys
szager@chromium.orgb4696232013-10-16 19:45:35 +000022import time
23import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000024import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000025from cStringIO import StringIO
26
szager@chromium.orgf202a252014-05-27 18:55:52 +000027
szager@chromium.orgb4696232013-10-16 19:45:35 +000028LOGGER = logging.getLogger()
29TRY_LIMIT = 5
30
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000031
szager@chromium.orgb4696232013-10-16 19:45:35 +000032# Controls the transport protocol used to communicate with gerrit.
33# This is parameterized primarily to enable GerritTestCase.
34GERRIT_PROTOCOL = 'https'
35
36
Dan Jacques1d949fd2016-11-15 10:41:48 -080037# Processing comments in "netrc" can trigger a bug in Windows.
38# See crbug.com/664664
39class safeNetrc(netrc.netrc):
40 # pylint: disable=redefined-builtin
41 def __init__(self, file=None):
42 self._orig_parse, self._parse = self._parse, self._safe_parse
43 netrc.netrc.__init__(self, file=file)
44
45 # pylint: disable=redefined-builtin
46 def _safe_parse(self, file, fp, default_netrc):
47 # Buffer the file.
48 sio = StringIO(''.join(l for l in fp
49 if l.strip() and not l.strip().startswith('#')))
50 return self._orig_parse(file, sio, default_netrc)
51
52
szager@chromium.orgb4696232013-10-16 19:45:35 +000053class GerritError(Exception):
54 """Exception class for errors commuicating with the gerrit-on-borg service."""
55 def __init__(self, http_status, *args, **kwargs):
56 super(GerritError, self).__init__(*args, **kwargs)
57 self.http_status = http_status
58 self.message = '(%d) %s' % (self.http_status, self.message)
59
60
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000061class GerritAuthenticationError(GerritError):
62 """Exception class for authentication errors during Gerrit communication."""
63
64
szager@chromium.orgb4696232013-10-16 19:45:35 +000065def _QueryString(param_dict, first_param=None):
66 """Encodes query parameters in the key:val[+key:val...] format specified here:
67
68 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
69 """
70 q = [urllib.quote(first_param)] if first_param else []
71 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
72 return '+'.join(q)
73
74
75def GetConnectionClass(protocol=None):
76 if protocol is None:
77 protocol = GERRIT_PROTOCOL
78 if protocol == 'https':
79 return httplib.HTTPSConnection
80 elif protocol == 'http':
81 return httplib.HTTPConnection
82 else:
83 raise RuntimeError(
84 "Don't know how to work with protocol '%s'" % protocol)
85
86
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000087class Authenticator(object):
88 """Base authenticator class for authenticator implementations to subclass."""
89
90 def get_auth_header(self, host):
91 raise NotImplementedError()
92
93 @staticmethod
94 def get():
95 """Returns: (Authenticator) The identified Authenticator to use.
96
97 Probes the local system and its environment and identifies the
98 Authenticator instance to use.
99 """
100 if GceAuthenticator.is_gce():
101 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000102 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000103
104
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000105class CookiesAuthenticator(Authenticator):
106 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
107
108 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000109 """
110
111 def __init__(self):
112 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000113 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000114
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000115 @classmethod
116 def get_new_password_message(cls, host):
117 assert not host.startswith('http')
118 # Assume *.googlesource.com pattern.
119 parts = host.split('.')
120 if not parts[0].endswith('-review'):
121 parts[0] += '-review'
122 url = 'https://%s/new-password' % ('.'.join(parts))
123 return 'You can (re)generate your credentails by visiting %s' % url
124
125 @classmethod
126 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000127 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000128 return os.path.expanduser(os.path.join('~', path))
129
130 @classmethod
131 def _get_netrc(cls):
132 path = cls.get_netrc_path()
133 if not os.path.exists(path):
Dan Jacques1d949fd2016-11-15 10:41:48 -0800134 return safeNetrc(os.devnull)
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000135
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000136 try:
Dan Jacques1d949fd2016-11-15 10:41:48 -0800137 return safeNetrc(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000138 except IOError:
139 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
Dan Jacques1d949fd2016-11-15 10:41:48 -0800140 return safeNetrc(os.devnull)
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000141 except netrc.NetrcParseError:
142 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000143 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
144 print >> sys.stderr, (
145 'WARNING: netrc file %s cannot be used because its file '
146 'permissions are insecure. netrc file permissions should be '
147 '600.' % path)
148 else:
149 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
150 'parsing error.' % path)
151 raise
Dan Jacques1d949fd2016-11-15 10:41:48 -0800152 return safeNetrc(os.devnull)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000153
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000154 @classmethod
155 def get_gitcookies_path(cls):
156 return os.path.join(os.environ['HOME'], '.gitcookies')
157
158 @classmethod
159 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000160 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000161 path = cls.get_gitcookies_path()
162 if not os.path.exists(path):
163 return gitcookies
164
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000165 try:
166 f = open(path, 'rb')
167 except IOError:
168 return gitcookies
169
170 with f:
171 for line in f:
172 try:
173 fields = line.strip().split('\t')
174 if line.strip().startswith('#') or len(fields) != 7:
175 continue
176 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
177 if xpath == '/' and key == 'o':
178 login, secret_token = value.split('=', 1)
179 gitcookies[domain] = (login, secret_token)
180 except (IndexError, ValueError, TypeError) as exc:
181 logging.warning(exc)
182
183 return gitcookies
184
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000185 def get_auth_header(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000186 auth = None
187 for domain, creds in self.gitcookies.iteritems():
188 if cookielib.domain_match(host, domain):
189 auth = (creds[0], None, creds[1])
190 break
191
192 if not auth:
193 auth = self.netrc.authenticators(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000194 if auth:
195 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
196 return None
197
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000198# Backwards compatibility just in case somebody imports this outside of
199# depot_tools.
200NetrcAuthenticator = CookiesAuthenticator
201
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000202
203class GceAuthenticator(Authenticator):
204 """Authenticator implementation that uses GCE metadata service for token.
205 """
206
207 _INFO_URL = 'http://metadata.google.internal'
208 _ACQUIRE_URL = ('http://metadata/computeMetadata/v1/instance/'
209 'service-accounts/default/token')
210 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
211
212 _cache_is_gce = None
213 _token_cache = None
214 _token_expiration = None
215
216 @classmethod
217 def is_gce(cls):
218 if cls._cache_is_gce is None:
219 cls._cache_is_gce = cls._test_is_gce()
220 return cls._cache_is_gce
221
222 @classmethod
223 def _test_is_gce(cls):
224 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
225 try:
226 resp = cls._get(cls._INFO_URL)
227 except socket.error:
228 # Could not resolve URL.
229 return False
230 return resp.getheader('Metadata-Flavor', None) == 'Google'
231
232 @staticmethod
233 def _get(url, **kwargs):
234 next_delay_sec = 1
235 for i in xrange(TRY_LIMIT):
236 if i > 0:
237 # Retry server error status codes.
238 LOGGER.info('Encountered server error; retrying after %d second(s).',
239 next_delay_sec)
240 time.sleep(next_delay_sec)
241 next_delay_sec *= 2
242
243 p = urlparse.urlparse(url)
244 c = GetConnectionClass(protocol=p.scheme)(p.netloc)
245 c.request('GET', url, **kwargs)
246 resp = c.getresponse()
247 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
248 if resp.status < httplib.INTERNAL_SERVER_ERROR:
249 return resp
250
251
252 @classmethod
253 def _get_token_dict(cls):
254 if cls._token_cache:
255 # If it expires within 25 seconds, refresh.
256 if cls._token_expiration < time.time() - 25:
257 return cls._token_cache
258
259 resp = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
260 if resp.status != httplib.OK:
261 return None
262 cls._token_cache = json.load(resp)
263 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
264 return cls._token_cache
265
266 def get_auth_header(self, _host):
267 token_dict = self._get_token_dict()
268 if not token_dict:
269 return None
270 return '%(token_type)s %(access_token)s' % token_dict
271
272
273
szager@chromium.orgb4696232013-10-16 19:45:35 +0000274def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
275 """Opens an https connection to a gerrit service, and sends a request."""
276 headers = headers or {}
277 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000278
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000279 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000280 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000281 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000282 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000283 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000284
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800285 url = path
286 if not url.startswith('/'):
287 url = '/' + url
288 if 'Authorization' in headers and not url.startswith('/a/'):
289 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000290
szager@chromium.orgb4696232013-10-16 19:45:35 +0000291 if body:
292 body = json.JSONEncoder().encode(body)
293 headers.setdefault('Content-Type', 'application/json')
294 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000295 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000296 for key, val in headers.iteritems():
297 if key == 'Authorization':
298 val = 'HIDDEN'
299 LOGGER.debug('%s: %s' % (key, val))
300 if body:
301 LOGGER.debug(body)
302 conn = GetConnectionClass()(host)
303 conn.req_host = host
304 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000305 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000306 'method': reqtype,
307 'headers': headers,
308 'body': body,
309 }
310 conn.request(**conn.req_params)
311 return conn
312
313
314def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
315 """Reads an http response from a connection into a string buffer.
316
317 Args:
318 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
319 expect_status: Success is indicated by this status in the response.
320 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
321 doesn't match the database contents. In most such cases, we
322 want the API to return None rather than raise an Exception.
323 Returns: A string buffer containing the connection's reply.
324 """
325
326 sleep_time = 0.5
327 for idx in range(TRY_LIMIT):
328 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000329
330 # Check if this is an authentication issue.
331 www_authenticate = response.getheader('www-authenticate')
332 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
333 www_authenticate):
334 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
335 host = auth_match.group(1) if auth_match else conn.req_host
336 reason = ('Authentication failed. Please make sure your .netrc file '
337 'has credentials for %s' % host)
338 raise GerritAuthenticationError(response.status, reason)
339
szager@chromium.orgb4696232013-10-16 19:45:35 +0000340 # If response.status < 500 then the result is final; break retry loop.
341 if response.status < 500:
342 break
343 # A status >=500 is assumed to be a possible transient error; retry.
344 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
345 msg = (
qyearsley12fa6ff2016-08-24 09:18:40 -0700346 'A transient error occurred while querying %s:\n'
szager@chromium.orgb4696232013-10-16 19:45:35 +0000347 '%s %s %s\n'
348 '%s %d %s' % (
349 conn.host, conn.req_params['method'], conn.req_params['url'],
350 http_version, http_version, response.status, response.reason))
351 if TRY_LIMIT - idx > 1:
352 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
353 time.sleep(sleep_time)
354 sleep_time = sleep_time * 2
355 req_host = conn.req_host
356 req_params = conn.req_params
357 conn = GetConnectionClass()(req_host)
358 conn.req_host = req_host
359 conn.req_params = req_params
360 conn.request(**req_params)
361 LOGGER.warn(msg)
362 if ignore_404 and response.status == 404:
363 return StringIO()
364 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000365 reason = '%s: %s' % (response.reason, response.read())
366 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000367 return StringIO(response.read())
368
369
370def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
371 """Parses an https response as json."""
372 fh = ReadHttpResponse(
373 conn, expect_status=expect_status, ignore_404=ignore_404)
374 # The first line of the response should always be: )]}'
375 s = fh.readline()
376 if s and s.rstrip() != ")]}'":
377 raise GerritError(200, 'Unexpected json output: %s' % s)
378 s = fh.read()
379 if not s:
380 return None
381 return json.loads(s)
382
383
384def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
385 sortkey=None):
386 """
387 Queries a gerrit-on-borg server for changes matching query terms.
388
389 Args:
390 param_dict: A dictionary of search parameters, as documented here:
391 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
392 first_param: A change identifier
393 limit: Maximum number of results to return.
394 o_params: A list of additional output specifiers, as documented here:
395 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
396 Returns:
397 A list of json-decoded query results.
398 """
399 # Note that no attempt is made to escape special characters; YMMV.
400 if not param_dict and not first_param:
401 raise RuntimeError('QueryChanges requires search parameters')
402 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
403 if sortkey:
404 path = '%s&N=%s' % (path, sortkey)
405 if limit:
406 path = '%s&n=%d' % (path, limit)
407 if o_params:
408 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
409 # Don't ignore 404; a query should always return a list, even if it's empty.
410 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
411
412
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000413def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
414 o_params=None, sortkey=None):
415 """
416 Queries a gerrit-on-borg server for all the changes matching the query terms.
417
418 A single query to gerrit-on-borg is limited on the number of results by the
419 limit parameter on the request (see QueryChanges) and the server maximum
420 limit. This function uses the "_more_changes" and "_sortkey" attributes on
421 the returned changes to iterate all of them making multiple queries to the
422 server, regardless the query limit.
423
424 Args:
425 param_dict, first_param: Refer to QueryChanges().
426 limit: Maximum number of requested changes per query.
427 o_params: Refer to QueryChanges().
428 sortkey: The value of the "_sortkey" attribute where starts from. None to
429 start from the first change.
430
431 Returns:
432 A generator object to the list of returned changes, possibly unbound.
433 """
434 more_changes = True
435 while more_changes:
436 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
437 for cl in page:
438 yield cl
439
440 more_changes = [cl for cl in page if '_more_changes' in cl]
441 if len(more_changes) > 1:
442 raise GerritError(
443 200,
444 'Received %d changes with a _more_changes attribute set but should '
445 'receive at most one.' % len(more_changes))
446 if more_changes:
447 sortkey = more_changes[0]['_sortkey']
448
449
szager@chromium.orgb4696232013-10-16 19:45:35 +0000450def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
451 sortkey=None):
452 """Initiate a query composed of multiple sets of query parameters."""
453 if not change_list:
454 raise RuntimeError(
455 "MultiQueryChanges requires a list of change numbers/id's")
456 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
457 if param_dict:
458 q.append(_QueryString(param_dict))
459 if limit:
460 q.append('n=%d' % limit)
461 if sortkey:
462 q.append('N=%s' % sortkey)
463 if o_params:
464 q.extend(['o=%s' % p for p in o_params])
465 path = 'changes/?%s' % '&'.join(q)
466 try:
467 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
468 except GerritError as e:
469 msg = '%s:\n%s' % (e.message, path)
470 raise GerritError(e.http_status, msg)
471 return result
472
473
474def GetGerritFetchUrl(host):
475 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
476 return '%s://%s/' % (GERRIT_PROTOCOL, host)
477
478
479def GetChangePageUrl(host, change_number):
480 """Given a gerrit host name and change number, return change page url."""
481 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
482
483
484def GetChangeUrl(host, change):
485 """Given a gerrit host name and change id, return an url for the change."""
486 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
487
488
489def GetChange(host, change):
490 """Query a gerrit server for information about a single change."""
491 path = 'changes/%s' % change
492 return ReadHttpJsonResponse(CreateHttpConn(host, path))
493
494
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100495def GetChangeDetail(host, change, o_params=None, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000496 """Query a gerrit server for extended information about a single change."""
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100497 # TODO(tandrii): cahnge ignore_404 to False by default.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000498 path = 'changes/%s/detail' % change
499 if o_params:
500 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100501 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=ignore_404)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000502
503
agable32978d92016-11-01 12:55:02 -0700504def GetChangeCommit(host, change, revision='current'):
505 """Query a gerrit server for a revision associated with a change."""
506 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
507 return ReadHttpJsonResponse(CreateHttpConn(host, path))
508
509
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000510def GetChangeDescriptionFromGitiles(url, revision):
511 """Query Gitiles for actual commit message for a given url and ref.
512
513 url must be obtained from call to GetChangeDetail for a specific
514 revision (patchset) under 'fetch' key.
515 """
516 parsed = urlparse.urlparse(url)
517 path = '%s/+/%s?format=json' % (parsed.path, revision)
tandrii@chromium.orgc767e3f2016-04-26 14:28:49 +0000518 # Note: Gerrit instances that Chrome infrastructure uses thus far have all
519 # enabled Gitiles, which allowes us to execute this call. This isn't true for
520 # all Gerrit instances out there. Thus, if line below fails, consider adding a
521 # fallback onto actually fetching ref from remote using pure git.
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000522 return ReadHttpJsonResponse(CreateHttpConn(parsed.netloc, path))['message']
523
524
szager@chromium.orgb4696232013-10-16 19:45:35 +0000525def GetChangeCurrentRevision(host, change):
526 """Get information about the latest revision for a given change."""
527 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
528
529
530def GetChangeRevisions(host, change):
531 """Get information about all revisions associated with a change."""
532 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
533
534
535def GetChangeReview(host, change, revision=None):
536 """Get the current review information for a change."""
537 if not revision:
538 jmsg = GetChangeRevisions(host, change)
539 if not jmsg:
540 return None
541 elif len(jmsg) > 1:
542 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
543 revision = jmsg[0]['current_revision']
544 path = 'changes/%s/revisions/%s/review'
545 return ReadHttpJsonResponse(CreateHttpConn(host, path))
546
547
548def AbandonChange(host, change, msg=''):
549 """Abandon a gerrit change."""
550 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000551 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000552 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
553 return ReadHttpJsonResponse(conn, ignore_404=False)
554
555
556def RestoreChange(host, change, msg=''):
557 """Restore a previously abandoned change."""
558 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000559 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000560 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
561 return ReadHttpJsonResponse(conn, ignore_404=False)
562
563
564def SubmitChange(host, change, wait_for_merge=True):
565 """Submits a gerrit change via Gerrit."""
566 path = 'changes/%s/submit' % change
567 body = {'wait_for_merge': wait_for_merge}
568 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
569 return ReadHttpJsonResponse(conn, ignore_404=False)
570
571
dsansomee2d6fd92016-09-08 00:10:47 -0700572def HasPendingChangeEdit(host, change):
573 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
574 try:
575 ReadHttpResponse(conn, ignore_404=False)
576 except GerritError as e:
577 # On success, gerrit returns status 204; anything else is an error.
578 if e.http_status != 204:
579 raise
580 return False
581 else:
582 return True
583
584
585def DeletePendingChangeEdit(host, change):
586 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
587 try:
588 ReadHttpResponse(conn, ignore_404=False)
589 except GerritError as e:
590 # On success, gerrit returns status 204; if the edit was already deleted it
591 # returns 404. Anything else is an error.
592 if e.http_status not in (204, 404):
593 raise
594
595
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000596def SetCommitMessage(host, change, description):
597 """Updates a commit message."""
598 # First, edit the commit message in a draft.
599 path = 'changes/%s/edit:message' % change
600 body = {'message': description}
601 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
602 try:
603 ReadHttpResponse(conn, ignore_404=False)
604 except GerritError as e:
605 # On success, gerrit returns status 204; anything else is an error.
606 if e.http_status != 204:
607 raise
608 else:
609 raise GerritError(
610 'Unexpectedly received a 200 http status while editing message in '
611 'change %s' % change)
612
613 # And then publish it.
614 path = 'changes/%s/edit:publish' % change
615 conn = CreateHttpConn(host, path, reqtype='POST', body={})
616 try:
617 ReadHttpResponse(conn, ignore_404=False)
618 except GerritError as e:
619 # On success, gerrit returns status 204; anything else is an error.
620 if e.http_status != 204:
621 raise
622 else:
623 raise GerritError(
624 'Unexpectedly received a 200 http status while publishing message '
625 'change in %s' % change)
626
627
szager@chromium.orgb4696232013-10-16 19:45:35 +0000628def GetReviewers(host, change):
629 """Get information about all reviewers attached to a change."""
630 path = 'changes/%s/reviewers' % change
631 return ReadHttpJsonResponse(CreateHttpConn(host, path))
632
633
634def GetReview(host, change, revision):
635 """Get review information about a specific revision of a change."""
636 path = 'changes/%s/revisions/%s/review' % (change, revision)
637 return ReadHttpJsonResponse(CreateHttpConn(host, path))
638
639
tandrii88189772016-09-29 04:29:57 -0700640def AddReviewers(host, change, add=None, is_reviewer=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000641 """Add reviewers to a change."""
Aaron Gabledf86e302016-11-08 10:48:03 -0800642 errors = None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000643 if not add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800644 return None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000645 if isinstance(add, basestring):
646 add = (add,)
647 path = 'changes/%s/reviewers' % change
648 for r in add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800649 state = 'REVIEWER' if is_reviewer else 'CC'
tandrii88189772016-09-29 04:29:57 -0700650 body = {
651 'reviewer': r,
Aaron Gabledf86e302016-11-08 10:48:03 -0800652 'state': state,
tandrii88189772016-09-29 04:29:57 -0700653 }
Aaron Gabledf86e302016-11-08 10:48:03 -0800654 try:
655 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
656 _ = ReadHttpJsonResponse(conn, ignore_404=False)
657 except GerritError as e:
658 if e.http_status == 422: # "Unprocessable Entity"
659 LOGGER.warn('Failed to add "%s" as a %s' % (r, state.lower()))
660 errors = True
661 else:
662 raise
663 return errors
szager@chromium.orgb4696232013-10-16 19:45:35 +0000664
665
666def RemoveReviewers(host, change, remove=None):
667 """Remove reveiewers from a change."""
668 if not remove:
669 return
670 if isinstance(remove, basestring):
671 remove = (remove,)
672 for r in remove:
673 path = 'changes/%s/reviewers/%s' % (change, r)
674 conn = CreateHttpConn(host, path, reqtype='DELETE')
675 try:
676 ReadHttpResponse(conn, ignore_404=False)
677 except GerritError as e:
678 # On success, gerrit returns status 204; anything else is an error.
679 if e.http_status != 204:
680 raise
681 else:
682 raise GerritError(
683 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
684 ' from change %s' % (r, change))
685
686
687def SetReview(host, change, msg=None, labels=None, notify=None):
688 """Set labels and/or add a message to a code review."""
689 if not msg and not labels:
690 return
691 path = 'changes/%s/revisions/current/review' % change
692 body = {}
693 if msg:
694 body['message'] = msg
695 if labels:
696 body['labels'] = labels
697 if notify:
698 body['notify'] = notify
699 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
700 response = ReadHttpJsonResponse(conn)
701 if labels:
702 for key, val in labels.iteritems():
703 if ('labels' not in response or key not in response['labels'] or
704 int(response['labels'][key] != int(val))):
705 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
706 key, change))
707
708
709def ResetReviewLabels(host, change, label, value='0', message=None,
710 notify=None):
711 """Reset the value of a given label for all reviewers on a change."""
712 # This is tricky, because we want to work on the "current revision", but
713 # there's always the risk that "current revision" will change in between
714 # API calls. So, we check "current revision" at the beginning and end; if
715 # it has changed, raise an exception.
716 jmsg = GetChangeCurrentRevision(host, change)
717 if not jmsg:
718 raise GerritError(
719 200, 'Could not get review information for change "%s"' % change)
720 value = str(value)
721 revision = jmsg[0]['current_revision']
722 path = 'changes/%s/revisions/%s/review' % (change, revision)
723 message = message or (
724 '%s label set to %s programmatically.' % (label, value))
725 jmsg = GetReview(host, change, revision)
726 if not jmsg:
727 raise GerritError(200, 'Could not get review information for revison %s '
728 'of change %s' % (revision, change))
729 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
730 if str(review.get('value', value)) != value:
731 body = {
732 'message': message,
733 'labels': {label: value},
734 'on_behalf_of': review['_account_id'],
735 }
736 if notify:
737 body['notify'] = notify
738 conn = CreateHttpConn(
739 host, path, reqtype='POST', body=body)
740 response = ReadHttpJsonResponse(conn)
741 if str(response['labels'][label]) != value:
742 username = review.get('email', jmsg.get('name', ''))
743 raise GerritError(200, 'Unable to set %s label for user "%s"'
744 ' on change %s.' % (label, username, change))
745 jmsg = GetChangeCurrentRevision(host, change)
746 if not jmsg:
747 raise GerritError(
748 200, 'Could not get review information for change "%s"' % change)
749 elif jmsg[0]['current_revision'] != revision:
750 raise GerritError(200, 'While resetting labels on change "%s", '
751 'a new patchset was uploaded.' % change)