blob: 0c73785e2aa2f4d74d20c3a57357f495a4307cc8 [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
37class GerritError(Exception):
38 """Exception class for errors commuicating with the gerrit-on-borg service."""
39 def __init__(self, http_status, *args, **kwargs):
40 super(GerritError, self).__init__(*args, **kwargs)
41 self.http_status = http_status
42 self.message = '(%d) %s' % (self.http_status, self.message)
43
44
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000045class GerritAuthenticationError(GerritError):
46 """Exception class for authentication errors during Gerrit communication."""
47
48
szager@chromium.orgb4696232013-10-16 19:45:35 +000049def _QueryString(param_dict, first_param=None):
50 """Encodes query parameters in the key:val[+key:val...] format specified here:
51
52 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
53 """
54 q = [urllib.quote(first_param)] if first_param else []
55 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
56 return '+'.join(q)
57
58
59def GetConnectionClass(protocol=None):
60 if protocol is None:
61 protocol = GERRIT_PROTOCOL
62 if protocol == 'https':
63 return httplib.HTTPSConnection
64 elif protocol == 'http':
65 return httplib.HTTPConnection
66 else:
67 raise RuntimeError(
68 "Don't know how to work with protocol '%s'" % protocol)
69
70
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000071class Authenticator(object):
72 """Base authenticator class for authenticator implementations to subclass."""
73
74 def get_auth_header(self, host):
75 raise NotImplementedError()
76
77 @staticmethod
78 def get():
79 """Returns: (Authenticator) The identified Authenticator to use.
80
81 Probes the local system and its environment and identifies the
82 Authenticator instance to use.
83 """
84 if GceAuthenticator.is_gce():
85 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000086 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000087
88
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000089class CookiesAuthenticator(Authenticator):
90 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
91
92 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000093 """
94
95 def __init__(self):
96 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000097 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000098
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000099 @classmethod
100 def get_new_password_message(cls, host):
101 assert not host.startswith('http')
102 # Assume *.googlesource.com pattern.
103 parts = host.split('.')
104 if not parts[0].endswith('-review'):
105 parts[0] += '-review'
106 url = 'https://%s/new-password' % ('.'.join(parts))
107 return 'You can (re)generate your credentails by visiting %s' % url
108
109 @classmethod
110 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000111 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000112 return os.path.expanduser(os.path.join('~', path))
113
114 @classmethod
115 def _get_netrc(cls):
116 path = cls.get_netrc_path()
117 if not os.path.exists(path):
118 return netrc.netrc(os.devnull)
119
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000120 try:
121 return netrc.netrc(path)
122 except IOError:
123 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
124 return netrc.netrc(os.devnull)
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000125 except netrc.NetrcParseError:
126 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000127 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
128 print >> sys.stderr, (
129 'WARNING: netrc file %s cannot be used because its file '
130 'permissions are insecure. netrc file permissions should be '
131 '600.' % path)
132 else:
133 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
134 'parsing error.' % path)
135 raise
136 return netrc.netrc(os.devnull)
137
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000138 @classmethod
139 def get_gitcookies_path(cls):
140 return os.path.join(os.environ['HOME'], '.gitcookies')
141
142 @classmethod
143 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000144 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000145 path = cls.get_gitcookies_path()
146 if not os.path.exists(path):
147 return gitcookies
148
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000149 try:
150 f = open(path, 'rb')
151 except IOError:
152 return gitcookies
153
154 with f:
155 for line in f:
156 try:
157 fields = line.strip().split('\t')
158 if line.strip().startswith('#') or len(fields) != 7:
159 continue
160 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
161 if xpath == '/' and key == 'o':
162 login, secret_token = value.split('=', 1)
163 gitcookies[domain] = (login, secret_token)
164 except (IndexError, ValueError, TypeError) as exc:
165 logging.warning(exc)
166
167 return gitcookies
168
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000169 def get_auth_header(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000170 auth = None
171 for domain, creds in self.gitcookies.iteritems():
172 if cookielib.domain_match(host, domain):
173 auth = (creds[0], None, creds[1])
174 break
175
176 if not auth:
177 auth = self.netrc.authenticators(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000178 if auth:
179 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
180 return None
181
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000182# Backwards compatibility just in case somebody imports this outside of
183# depot_tools.
184NetrcAuthenticator = CookiesAuthenticator
185
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000186
187class GceAuthenticator(Authenticator):
188 """Authenticator implementation that uses GCE metadata service for token.
189 """
190
191 _INFO_URL = 'http://metadata.google.internal'
192 _ACQUIRE_URL = ('http://metadata/computeMetadata/v1/instance/'
193 'service-accounts/default/token')
194 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
195
196 _cache_is_gce = None
197 _token_cache = None
198 _token_expiration = None
199
200 @classmethod
201 def is_gce(cls):
202 if cls._cache_is_gce is None:
203 cls._cache_is_gce = cls._test_is_gce()
204 return cls._cache_is_gce
205
206 @classmethod
207 def _test_is_gce(cls):
208 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
209 try:
210 resp = cls._get(cls._INFO_URL)
211 except socket.error:
212 # Could not resolve URL.
213 return False
214 return resp.getheader('Metadata-Flavor', None) == 'Google'
215
216 @staticmethod
217 def _get(url, **kwargs):
218 next_delay_sec = 1
219 for i in xrange(TRY_LIMIT):
220 if i > 0:
221 # Retry server error status codes.
222 LOGGER.info('Encountered server error; retrying after %d second(s).',
223 next_delay_sec)
224 time.sleep(next_delay_sec)
225 next_delay_sec *= 2
226
227 p = urlparse.urlparse(url)
228 c = GetConnectionClass(protocol=p.scheme)(p.netloc)
229 c.request('GET', url, **kwargs)
230 resp = c.getresponse()
231 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
232 if resp.status < httplib.INTERNAL_SERVER_ERROR:
233 return resp
234
235
236 @classmethod
237 def _get_token_dict(cls):
238 if cls._token_cache:
239 # If it expires within 25 seconds, refresh.
240 if cls._token_expiration < time.time() - 25:
241 return cls._token_cache
242
243 resp = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
244 if resp.status != httplib.OK:
245 return None
246 cls._token_cache = json.load(resp)
247 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
248 return cls._token_cache
249
250 def get_auth_header(self, _host):
251 token_dict = self._get_token_dict()
252 if not token_dict:
253 return None
254 return '%(token_type)s %(access_token)s' % token_dict
255
256
257
szager@chromium.orgb4696232013-10-16 19:45:35 +0000258def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
259 """Opens an https connection to a gerrit service, and sends a request."""
260 headers = headers or {}
261 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000262
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000263 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000264 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000265 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000266 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000267 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000268
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800269 url = path
270 if not url.startswith('/'):
271 url = '/' + url
272 if 'Authorization' in headers and not url.startswith('/a/'):
273 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000274
szager@chromium.orgb4696232013-10-16 19:45:35 +0000275 if body:
276 body = json.JSONEncoder().encode(body)
277 headers.setdefault('Content-Type', 'application/json')
278 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000279 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000280 for key, val in headers.iteritems():
281 if key == 'Authorization':
282 val = 'HIDDEN'
283 LOGGER.debug('%s: %s' % (key, val))
284 if body:
285 LOGGER.debug(body)
286 conn = GetConnectionClass()(host)
287 conn.req_host = host
288 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000289 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000290 'method': reqtype,
291 'headers': headers,
292 'body': body,
293 }
294 conn.request(**conn.req_params)
295 return conn
296
297
298def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
299 """Reads an http response from a connection into a string buffer.
300
301 Args:
302 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
303 expect_status: Success is indicated by this status in the response.
304 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
305 doesn't match the database contents. In most such cases, we
306 want the API to return None rather than raise an Exception.
307 Returns: A string buffer containing the connection's reply.
308 """
309
310 sleep_time = 0.5
311 for idx in range(TRY_LIMIT):
312 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000313
314 # Check if this is an authentication issue.
315 www_authenticate = response.getheader('www-authenticate')
316 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
317 www_authenticate):
318 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
319 host = auth_match.group(1) if auth_match else conn.req_host
320 reason = ('Authentication failed. Please make sure your .netrc file '
321 'has credentials for %s' % host)
322 raise GerritAuthenticationError(response.status, reason)
323
szager@chromium.orgb4696232013-10-16 19:45:35 +0000324 # If response.status < 500 then the result is final; break retry loop.
325 if response.status < 500:
326 break
327 # A status >=500 is assumed to be a possible transient error; retry.
328 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
329 msg = (
qyearsley12fa6ff2016-08-24 09:18:40 -0700330 'A transient error occurred while querying %s:\n'
szager@chromium.orgb4696232013-10-16 19:45:35 +0000331 '%s %s %s\n'
332 '%s %d %s' % (
333 conn.host, conn.req_params['method'], conn.req_params['url'],
334 http_version, http_version, response.status, response.reason))
335 if TRY_LIMIT - idx > 1:
336 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
337 time.sleep(sleep_time)
338 sleep_time = sleep_time * 2
339 req_host = conn.req_host
340 req_params = conn.req_params
341 conn = GetConnectionClass()(req_host)
342 conn.req_host = req_host
343 conn.req_params = req_params
344 conn.request(**req_params)
345 LOGGER.warn(msg)
346 if ignore_404 and response.status == 404:
347 return StringIO()
348 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000349 reason = '%s: %s' % (response.reason, response.read())
350 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000351 return StringIO(response.read())
352
353
354def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
355 """Parses an https response as json."""
356 fh = ReadHttpResponse(
357 conn, expect_status=expect_status, ignore_404=ignore_404)
358 # The first line of the response should always be: )]}'
359 s = fh.readline()
360 if s and s.rstrip() != ")]}'":
361 raise GerritError(200, 'Unexpected json output: %s' % s)
362 s = fh.read()
363 if not s:
364 return None
365 return json.loads(s)
366
367
368def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
369 sortkey=None):
370 """
371 Queries a gerrit-on-borg server for changes matching query terms.
372
373 Args:
374 param_dict: A dictionary of search parameters, as documented here:
375 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
376 first_param: A change identifier
377 limit: Maximum number of results to return.
378 o_params: A list of additional output specifiers, as documented here:
379 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
380 Returns:
381 A list of json-decoded query results.
382 """
383 # Note that no attempt is made to escape special characters; YMMV.
384 if not param_dict and not first_param:
385 raise RuntimeError('QueryChanges requires search parameters')
386 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
387 if sortkey:
388 path = '%s&N=%s' % (path, sortkey)
389 if limit:
390 path = '%s&n=%d' % (path, limit)
391 if o_params:
392 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
393 # Don't ignore 404; a query should always return a list, even if it's empty.
394 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
395
396
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000397def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
398 o_params=None, sortkey=None):
399 """
400 Queries a gerrit-on-borg server for all the changes matching the query terms.
401
402 A single query to gerrit-on-borg is limited on the number of results by the
403 limit parameter on the request (see QueryChanges) and the server maximum
404 limit. This function uses the "_more_changes" and "_sortkey" attributes on
405 the returned changes to iterate all of them making multiple queries to the
406 server, regardless the query limit.
407
408 Args:
409 param_dict, first_param: Refer to QueryChanges().
410 limit: Maximum number of requested changes per query.
411 o_params: Refer to QueryChanges().
412 sortkey: The value of the "_sortkey" attribute where starts from. None to
413 start from the first change.
414
415 Returns:
416 A generator object to the list of returned changes, possibly unbound.
417 """
418 more_changes = True
419 while more_changes:
420 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
421 for cl in page:
422 yield cl
423
424 more_changes = [cl for cl in page if '_more_changes' in cl]
425 if len(more_changes) > 1:
426 raise GerritError(
427 200,
428 'Received %d changes with a _more_changes attribute set but should '
429 'receive at most one.' % len(more_changes))
430 if more_changes:
431 sortkey = more_changes[0]['_sortkey']
432
433
szager@chromium.orgb4696232013-10-16 19:45:35 +0000434def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
435 sortkey=None):
436 """Initiate a query composed of multiple sets of query parameters."""
437 if not change_list:
438 raise RuntimeError(
439 "MultiQueryChanges requires a list of change numbers/id's")
440 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
441 if param_dict:
442 q.append(_QueryString(param_dict))
443 if limit:
444 q.append('n=%d' % limit)
445 if sortkey:
446 q.append('N=%s' % sortkey)
447 if o_params:
448 q.extend(['o=%s' % p for p in o_params])
449 path = 'changes/?%s' % '&'.join(q)
450 try:
451 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
452 except GerritError as e:
453 msg = '%s:\n%s' % (e.message, path)
454 raise GerritError(e.http_status, msg)
455 return result
456
457
458def GetGerritFetchUrl(host):
459 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
460 return '%s://%s/' % (GERRIT_PROTOCOL, host)
461
462
463def GetChangePageUrl(host, change_number):
464 """Given a gerrit host name and change number, return change page url."""
465 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
466
467
468def GetChangeUrl(host, change):
469 """Given a gerrit host name and change id, return an url for the change."""
470 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
471
472
473def GetChange(host, change):
474 """Query a gerrit server for information about a single change."""
475 path = 'changes/%s' % change
476 return ReadHttpJsonResponse(CreateHttpConn(host, path))
477
478
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100479def GetChangeDetail(host, change, o_params=None, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000480 """Query a gerrit server for extended information about a single change."""
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100481 # TODO(tandrii): cahnge ignore_404 to False by default.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000482 path = 'changes/%s/detail' % change
483 if o_params:
484 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100485 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=ignore_404)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000486
487
agable32978d92016-11-01 12:55:02 -0700488def GetChangeCommit(host, change, revision='current'):
489 """Query a gerrit server for a revision associated with a change."""
490 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
491 return ReadHttpJsonResponse(CreateHttpConn(host, path))
492
493
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000494def GetChangeDescriptionFromGitiles(url, revision):
495 """Query Gitiles for actual commit message for a given url and ref.
496
497 url must be obtained from call to GetChangeDetail for a specific
498 revision (patchset) under 'fetch' key.
499 """
500 parsed = urlparse.urlparse(url)
501 path = '%s/+/%s?format=json' % (parsed.path, revision)
tandrii@chromium.orgc767e3f2016-04-26 14:28:49 +0000502 # Note: Gerrit instances that Chrome infrastructure uses thus far have all
503 # enabled Gitiles, which allowes us to execute this call. This isn't true for
504 # all Gerrit instances out there. Thus, if line below fails, consider adding a
505 # fallback onto actually fetching ref from remote using pure git.
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000506 return ReadHttpJsonResponse(CreateHttpConn(parsed.netloc, path))['message']
507
508
szager@chromium.orgb4696232013-10-16 19:45:35 +0000509def GetChangeCurrentRevision(host, change):
510 """Get information about the latest revision for a given change."""
511 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
512
513
514def GetChangeRevisions(host, change):
515 """Get information about all revisions associated with a change."""
516 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
517
518
519def GetChangeReview(host, change, revision=None):
520 """Get the current review information for a change."""
521 if not revision:
522 jmsg = GetChangeRevisions(host, change)
523 if not jmsg:
524 return None
525 elif len(jmsg) > 1:
526 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
527 revision = jmsg[0]['current_revision']
528 path = 'changes/%s/revisions/%s/review'
529 return ReadHttpJsonResponse(CreateHttpConn(host, path))
530
531
532def AbandonChange(host, change, msg=''):
533 """Abandon a gerrit change."""
534 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000535 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000536 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
537 return ReadHttpJsonResponse(conn, ignore_404=False)
538
539
540def RestoreChange(host, change, msg=''):
541 """Restore a previously abandoned change."""
542 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000543 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000544 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
545 return ReadHttpJsonResponse(conn, ignore_404=False)
546
547
548def SubmitChange(host, change, wait_for_merge=True):
549 """Submits a gerrit change via Gerrit."""
550 path = 'changes/%s/submit' % change
551 body = {'wait_for_merge': wait_for_merge}
552 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
553 return ReadHttpJsonResponse(conn, ignore_404=False)
554
555
dsansomee2d6fd92016-09-08 00:10:47 -0700556def HasPendingChangeEdit(host, change):
557 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
558 try:
559 ReadHttpResponse(conn, ignore_404=False)
560 except GerritError as e:
561 # On success, gerrit returns status 204; anything else is an error.
562 if e.http_status != 204:
563 raise
564 return False
565 else:
566 return True
567
568
569def DeletePendingChangeEdit(host, change):
570 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
571 try:
572 ReadHttpResponse(conn, ignore_404=False)
573 except GerritError as e:
574 # On success, gerrit returns status 204; if the edit was already deleted it
575 # returns 404. Anything else is an error.
576 if e.http_status not in (204, 404):
577 raise
578
579
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000580def SetCommitMessage(host, change, description):
581 """Updates a commit message."""
582 # First, edit the commit message in a draft.
583 path = 'changes/%s/edit:message' % change
584 body = {'message': description}
585 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
586 try:
587 ReadHttpResponse(conn, ignore_404=False)
588 except GerritError as e:
589 # On success, gerrit returns status 204; anything else is an error.
590 if e.http_status != 204:
591 raise
592 else:
593 raise GerritError(
594 'Unexpectedly received a 200 http status while editing message in '
595 'change %s' % change)
596
597 # And then publish it.
598 path = 'changes/%s/edit:publish' % change
599 conn = CreateHttpConn(host, path, reqtype='POST', body={})
600 try:
601 ReadHttpResponse(conn, ignore_404=False)
602 except GerritError as e:
603 # On success, gerrit returns status 204; anything else is an error.
604 if e.http_status != 204:
605 raise
606 else:
607 raise GerritError(
608 'Unexpectedly received a 200 http status while publishing message '
609 'change in %s' % change)
610
611
szager@chromium.orgb4696232013-10-16 19:45:35 +0000612def GetReviewers(host, change):
613 """Get information about all reviewers attached to a change."""
614 path = 'changes/%s/reviewers' % change
615 return ReadHttpJsonResponse(CreateHttpConn(host, path))
616
617
618def GetReview(host, change, revision):
619 """Get review information about a specific revision of a change."""
620 path = 'changes/%s/revisions/%s/review' % (change, revision)
621 return ReadHttpJsonResponse(CreateHttpConn(host, path))
622
623
tandrii88189772016-09-29 04:29:57 -0700624def AddReviewers(host, change, add=None, is_reviewer=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000625 """Add reviewers to a change."""
Aaron Gabledf86e302016-11-08 10:48:03 -0800626 errors = None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000627 if not add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800628 return None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000629 if isinstance(add, basestring):
630 add = (add,)
631 path = 'changes/%s/reviewers' % change
632 for r in add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800633 state = 'REVIEWER' if is_reviewer else 'CC'
tandrii88189772016-09-29 04:29:57 -0700634 body = {
635 'reviewer': r,
Aaron Gabledf86e302016-11-08 10:48:03 -0800636 'state': state,
tandrii88189772016-09-29 04:29:57 -0700637 }
Aaron Gabledf86e302016-11-08 10:48:03 -0800638 try:
639 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
640 _ = ReadHttpJsonResponse(conn, ignore_404=False)
641 except GerritError as e:
642 if e.http_status == 422: # "Unprocessable Entity"
643 LOGGER.warn('Failed to add "%s" as a %s' % (r, state.lower()))
644 errors = True
645 else:
646 raise
647 return errors
szager@chromium.orgb4696232013-10-16 19:45:35 +0000648
649
650def RemoveReviewers(host, change, remove=None):
651 """Remove reveiewers from a change."""
652 if not remove:
653 return
654 if isinstance(remove, basestring):
655 remove = (remove,)
656 for r in remove:
657 path = 'changes/%s/reviewers/%s' % (change, r)
658 conn = CreateHttpConn(host, path, reqtype='DELETE')
659 try:
660 ReadHttpResponse(conn, ignore_404=False)
661 except GerritError as e:
662 # On success, gerrit returns status 204; anything else is an error.
663 if e.http_status != 204:
664 raise
665 else:
666 raise GerritError(
667 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
668 ' from change %s' % (r, change))
669
670
671def SetReview(host, change, msg=None, labels=None, notify=None):
672 """Set labels and/or add a message to a code review."""
673 if not msg and not labels:
674 return
675 path = 'changes/%s/revisions/current/review' % change
676 body = {}
677 if msg:
678 body['message'] = msg
679 if labels:
680 body['labels'] = labels
681 if notify:
682 body['notify'] = notify
683 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
684 response = ReadHttpJsonResponse(conn)
685 if labels:
686 for key, val in labels.iteritems():
687 if ('labels' not in response or key not in response['labels'] or
688 int(response['labels'][key] != int(val))):
689 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
690 key, change))
691
692
693def ResetReviewLabels(host, change, label, value='0', message=None,
694 notify=None):
695 """Reset the value of a given label for all reviewers on a change."""
696 # This is tricky, because we want to work on the "current revision", but
697 # there's always the risk that "current revision" will change in between
698 # API calls. So, we check "current revision" at the beginning and end; if
699 # it has changed, raise an exception.
700 jmsg = GetChangeCurrentRevision(host, change)
701 if not jmsg:
702 raise GerritError(
703 200, 'Could not get review information for change "%s"' % change)
704 value = str(value)
705 revision = jmsg[0]['current_revision']
706 path = 'changes/%s/revisions/%s/review' % (change, revision)
707 message = message or (
708 '%s label set to %s programmatically.' % (label, value))
709 jmsg = GetReview(host, change, revision)
710 if not jmsg:
711 raise GerritError(200, 'Could not get review information for revison %s '
712 'of change %s' % (revision, change))
713 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
714 if str(review.get('value', value)) != value:
715 body = {
716 'message': message,
717 'labels': {label: value},
718 'on_behalf_of': review['_account_id'],
719 }
720 if notify:
721 body['notify'] = notify
722 conn = CreateHttpConn(
723 host, path, reqtype='POST', body=body)
724 response = ReadHttpJsonResponse(conn)
725 if str(response['labels'][label]) != value:
726 username = review.get('email', jmsg.get('name', ''))
727 raise GerritError(200, 'Unable to set %s label for user "%s"'
728 ' on change %s.' % (label, username, change))
729 jmsg = GetChangeCurrentRevision(host, change)
730 if not jmsg:
731 raise GerritError(
732 200, 'Could not get review information for change "%s"' % change)
733 elif jmsg[0]['current_revision'] != revision:
734 raise GerritError(200, 'While resetting labels on change "%s", '
735 'a new patchset was uploaded.' % change)