blob: 6ea57f5a8cd19313cbc33071364b403c103acd0f [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
269 if 'Authorization' in headers and not path.startswith('a/'):
270 url = '/a/%s' % path
271 else:
272 url = '/%s' % path
273
szager@chromium.orgb4696232013-10-16 19:45:35 +0000274 if body:
275 body = json.JSONEncoder().encode(body)
276 headers.setdefault('Content-Type', 'application/json')
277 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000278 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000279 for key, val in headers.iteritems():
280 if key == 'Authorization':
281 val = 'HIDDEN'
282 LOGGER.debug('%s: %s' % (key, val))
283 if body:
284 LOGGER.debug(body)
285 conn = GetConnectionClass()(host)
286 conn.req_host = host
287 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000288 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000289 'method': reqtype,
290 'headers': headers,
291 'body': body,
292 }
293 conn.request(**conn.req_params)
294 return conn
295
296
297def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
298 """Reads an http response from a connection into a string buffer.
299
300 Args:
301 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
302 expect_status: Success is indicated by this status in the response.
303 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
304 doesn't match the database contents. In most such cases, we
305 want the API to return None rather than raise an Exception.
306 Returns: A string buffer containing the connection's reply.
307 """
308
309 sleep_time = 0.5
310 for idx in range(TRY_LIMIT):
311 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000312
313 # Check if this is an authentication issue.
314 www_authenticate = response.getheader('www-authenticate')
315 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
316 www_authenticate):
317 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
318 host = auth_match.group(1) if auth_match else conn.req_host
319 reason = ('Authentication failed. Please make sure your .netrc file '
320 'has credentials for %s' % host)
321 raise GerritAuthenticationError(response.status, reason)
322
szager@chromium.orgb4696232013-10-16 19:45:35 +0000323 # If response.status < 500 then the result is final; break retry loop.
324 if response.status < 500:
325 break
326 # A status >=500 is assumed to be a possible transient error; retry.
327 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
328 msg = (
qyearsley12fa6ff2016-08-24 09:18:40 -0700329 'A transient error occurred while querying %s:\n'
szager@chromium.orgb4696232013-10-16 19:45:35 +0000330 '%s %s %s\n'
331 '%s %d %s' % (
332 conn.host, conn.req_params['method'], conn.req_params['url'],
333 http_version, http_version, response.status, response.reason))
334 if TRY_LIMIT - idx > 1:
335 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
336 time.sleep(sleep_time)
337 sleep_time = sleep_time * 2
338 req_host = conn.req_host
339 req_params = conn.req_params
340 conn = GetConnectionClass()(req_host)
341 conn.req_host = req_host
342 conn.req_params = req_params
343 conn.request(**req_params)
344 LOGGER.warn(msg)
345 if ignore_404 and response.status == 404:
346 return StringIO()
347 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000348 reason = '%s: %s' % (response.reason, response.read())
349 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000350 return StringIO(response.read())
351
352
353def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
354 """Parses an https response as json."""
355 fh = ReadHttpResponse(
356 conn, expect_status=expect_status, ignore_404=ignore_404)
357 # The first line of the response should always be: )]}'
358 s = fh.readline()
359 if s and s.rstrip() != ")]}'":
360 raise GerritError(200, 'Unexpected json output: %s' % s)
361 s = fh.read()
362 if not s:
363 return None
364 return json.loads(s)
365
366
367def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
368 sortkey=None):
369 """
370 Queries a gerrit-on-borg server for changes matching query terms.
371
372 Args:
373 param_dict: A dictionary of search parameters, as documented here:
374 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
375 first_param: A change identifier
376 limit: Maximum number of results to return.
377 o_params: A list of additional output specifiers, as documented here:
378 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
379 Returns:
380 A list of json-decoded query results.
381 """
382 # Note that no attempt is made to escape special characters; YMMV.
383 if not param_dict and not first_param:
384 raise RuntimeError('QueryChanges requires search parameters')
385 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
386 if sortkey:
387 path = '%s&N=%s' % (path, sortkey)
388 if limit:
389 path = '%s&n=%d' % (path, limit)
390 if o_params:
391 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
392 # Don't ignore 404; a query should always return a list, even if it's empty.
393 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
394
395
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000396def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
397 o_params=None, sortkey=None):
398 """
399 Queries a gerrit-on-borg server for all the changes matching the query terms.
400
401 A single query to gerrit-on-borg is limited on the number of results by the
402 limit parameter on the request (see QueryChanges) and the server maximum
403 limit. This function uses the "_more_changes" and "_sortkey" attributes on
404 the returned changes to iterate all of them making multiple queries to the
405 server, regardless the query limit.
406
407 Args:
408 param_dict, first_param: Refer to QueryChanges().
409 limit: Maximum number of requested changes per query.
410 o_params: Refer to QueryChanges().
411 sortkey: The value of the "_sortkey" attribute where starts from. None to
412 start from the first change.
413
414 Returns:
415 A generator object to the list of returned changes, possibly unbound.
416 """
417 more_changes = True
418 while more_changes:
419 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
420 for cl in page:
421 yield cl
422
423 more_changes = [cl for cl in page if '_more_changes' in cl]
424 if len(more_changes) > 1:
425 raise GerritError(
426 200,
427 'Received %d changes with a _more_changes attribute set but should '
428 'receive at most one.' % len(more_changes))
429 if more_changes:
430 sortkey = more_changes[0]['_sortkey']
431
432
szager@chromium.orgb4696232013-10-16 19:45:35 +0000433def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
434 sortkey=None):
435 """Initiate a query composed of multiple sets of query parameters."""
436 if not change_list:
437 raise RuntimeError(
438 "MultiQueryChanges requires a list of change numbers/id's")
439 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
440 if param_dict:
441 q.append(_QueryString(param_dict))
442 if limit:
443 q.append('n=%d' % limit)
444 if sortkey:
445 q.append('N=%s' % sortkey)
446 if o_params:
447 q.extend(['o=%s' % p for p in o_params])
448 path = 'changes/?%s' % '&'.join(q)
449 try:
450 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
451 except GerritError as e:
452 msg = '%s:\n%s' % (e.message, path)
453 raise GerritError(e.http_status, msg)
454 return result
455
456
457def GetGerritFetchUrl(host):
458 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
459 return '%s://%s/' % (GERRIT_PROTOCOL, host)
460
461
462def GetChangePageUrl(host, change_number):
463 """Given a gerrit host name and change number, return change page url."""
464 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
465
466
467def GetChangeUrl(host, change):
468 """Given a gerrit host name and change id, return an url for the change."""
469 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
470
471
472def GetChange(host, change):
473 """Query a gerrit server for information about a single change."""
474 path = 'changes/%s' % change
475 return ReadHttpJsonResponse(CreateHttpConn(host, path))
476
477
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100478def GetChangeDetail(host, change, o_params=None, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000479 """Query a gerrit server for extended information about a single change."""
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100480 # TODO(tandrii): cahnge ignore_404 to False by default.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000481 path = 'changes/%s/detail' % change
482 if o_params:
483 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Andrii Shyshkalovc6c8b4c2016-11-09 20:51:20 +0100484 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=ignore_404)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000485
486
agable32978d92016-11-01 12:55:02 -0700487def GetChangeCommit(host, change, revision='current'):
488 """Query a gerrit server for a revision associated with a change."""
489 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
490 return ReadHttpJsonResponse(CreateHttpConn(host, path))
491
492
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000493def GetChangeDescriptionFromGitiles(url, revision):
494 """Query Gitiles for actual commit message for a given url and ref.
495
496 url must be obtained from call to GetChangeDetail for a specific
497 revision (patchset) under 'fetch' key.
498 """
499 parsed = urlparse.urlparse(url)
500 path = '%s/+/%s?format=json' % (parsed.path, revision)
tandrii@chromium.orgc767e3f2016-04-26 14:28:49 +0000501 # Note: Gerrit instances that Chrome infrastructure uses thus far have all
502 # enabled Gitiles, which allowes us to execute this call. This isn't true for
503 # all Gerrit instances out there. Thus, if line below fails, consider adding a
504 # fallback onto actually fetching ref from remote using pure git.
tandrii@chromium.org2d3da632016-04-25 19:23:27 +0000505 return ReadHttpJsonResponse(CreateHttpConn(parsed.netloc, path))['message']
506
507
szager@chromium.orgb4696232013-10-16 19:45:35 +0000508def GetChangeCurrentRevision(host, change):
509 """Get information about the latest revision for a given change."""
510 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
511
512
513def GetChangeRevisions(host, change):
514 """Get information about all revisions associated with a change."""
515 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
516
517
518def GetChangeReview(host, change, revision=None):
519 """Get the current review information for a change."""
520 if not revision:
521 jmsg = GetChangeRevisions(host, change)
522 if not jmsg:
523 return None
524 elif len(jmsg) > 1:
525 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
526 revision = jmsg[0]['current_revision']
527 path = 'changes/%s/revisions/%s/review'
528 return ReadHttpJsonResponse(CreateHttpConn(host, path))
529
530
531def AbandonChange(host, change, msg=''):
532 """Abandon a gerrit change."""
533 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000534 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000535 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
536 return ReadHttpJsonResponse(conn, ignore_404=False)
537
538
539def RestoreChange(host, change, msg=''):
540 """Restore a previously abandoned change."""
541 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000542 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000543 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
544 return ReadHttpJsonResponse(conn, ignore_404=False)
545
546
547def SubmitChange(host, change, wait_for_merge=True):
548 """Submits a gerrit change via Gerrit."""
549 path = 'changes/%s/submit' % change
550 body = {'wait_for_merge': wait_for_merge}
551 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
552 return ReadHttpJsonResponse(conn, ignore_404=False)
553
554
dsansomee2d6fd92016-09-08 00:10:47 -0700555def HasPendingChangeEdit(host, change):
556 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
557 try:
558 ReadHttpResponse(conn, ignore_404=False)
559 except GerritError as e:
560 # On success, gerrit returns status 204; anything else is an error.
561 if e.http_status != 204:
562 raise
563 return False
564 else:
565 return True
566
567
568def DeletePendingChangeEdit(host, change):
569 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
570 try:
571 ReadHttpResponse(conn, ignore_404=False)
572 except GerritError as e:
573 # On success, gerrit returns status 204; if the edit was already deleted it
574 # returns 404. Anything else is an error.
575 if e.http_status not in (204, 404):
576 raise
577
578
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000579def SetCommitMessage(host, change, description):
580 """Updates a commit message."""
581 # First, edit the commit message in a draft.
582 path = 'changes/%s/edit:message' % change
583 body = {'message': description}
584 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
585 try:
586 ReadHttpResponse(conn, ignore_404=False)
587 except GerritError as e:
588 # On success, gerrit returns status 204; anything else is an error.
589 if e.http_status != 204:
590 raise
591 else:
592 raise GerritError(
593 'Unexpectedly received a 200 http status while editing message in '
594 'change %s' % change)
595
596 # And then publish it.
597 path = 'changes/%s/edit:publish' % change
598 conn = CreateHttpConn(host, path, reqtype='POST', body={})
599 try:
600 ReadHttpResponse(conn, ignore_404=False)
601 except GerritError as e:
602 # On success, gerrit returns status 204; anything else is an error.
603 if e.http_status != 204:
604 raise
605 else:
606 raise GerritError(
607 'Unexpectedly received a 200 http status while publishing message '
608 'change in %s' % change)
609
610
szager@chromium.orgb4696232013-10-16 19:45:35 +0000611def GetReviewers(host, change):
612 """Get information about all reviewers attached to a change."""
613 path = 'changes/%s/reviewers' % change
614 return ReadHttpJsonResponse(CreateHttpConn(host, path))
615
616
617def GetReview(host, change, revision):
618 """Get review information about a specific revision of a change."""
619 path = 'changes/%s/revisions/%s/review' % (change, revision)
620 return ReadHttpJsonResponse(CreateHttpConn(host, path))
621
622
tandrii88189772016-09-29 04:29:57 -0700623def AddReviewers(host, change, add=None, is_reviewer=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000624 """Add reviewers to a change."""
Aaron Gabledf86e302016-11-08 10:48:03 -0800625 errors = None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000626 if not add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800627 return None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000628 if isinstance(add, basestring):
629 add = (add,)
630 path = 'changes/%s/reviewers' % change
631 for r in add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800632 state = 'REVIEWER' if is_reviewer else 'CC'
tandrii88189772016-09-29 04:29:57 -0700633 body = {
634 'reviewer': r,
Aaron Gabledf86e302016-11-08 10:48:03 -0800635 'state': state,
tandrii88189772016-09-29 04:29:57 -0700636 }
Aaron Gabledf86e302016-11-08 10:48:03 -0800637 try:
638 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
639 _ = ReadHttpJsonResponse(conn, ignore_404=False)
640 except GerritError as e:
641 if e.http_status == 422: # "Unprocessable Entity"
642 LOGGER.warn('Failed to add "%s" as a %s' % (r, state.lower()))
643 errors = True
644 else:
645 raise
646 return errors
szager@chromium.orgb4696232013-10-16 19:45:35 +0000647
648
649def RemoveReviewers(host, change, remove=None):
650 """Remove reveiewers from a change."""
651 if not remove:
652 return
653 if isinstance(remove, basestring):
654 remove = (remove,)
655 for r in remove:
656 path = 'changes/%s/reviewers/%s' % (change, r)
657 conn = CreateHttpConn(host, path, reqtype='DELETE')
658 try:
659 ReadHttpResponse(conn, ignore_404=False)
660 except GerritError as e:
661 # On success, gerrit returns status 204; anything else is an error.
662 if e.http_status != 204:
663 raise
664 else:
665 raise GerritError(
666 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
667 ' from change %s' % (r, change))
668
669
670def SetReview(host, change, msg=None, labels=None, notify=None):
671 """Set labels and/or add a message to a code review."""
672 if not msg and not labels:
673 return
674 path = 'changes/%s/revisions/current/review' % change
675 body = {}
676 if msg:
677 body['message'] = msg
678 if labels:
679 body['labels'] = labels
680 if notify:
681 body['notify'] = notify
682 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
683 response = ReadHttpJsonResponse(conn)
684 if labels:
685 for key, val in labels.iteritems():
686 if ('labels' not in response or key not in response['labels'] or
687 int(response['labels'][key] != int(val))):
688 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
689 key, change))
690
691
692def ResetReviewLabels(host, change, label, value='0', message=None,
693 notify=None):
694 """Reset the value of a given label for all reviewers on a change."""
695 # This is tricky, because we want to work on the "current revision", but
696 # there's always the risk that "current revision" will change in between
697 # API calls. So, we check "current revision" at the beginning and end; if
698 # it has changed, raise an exception.
699 jmsg = GetChangeCurrentRevision(host, change)
700 if not jmsg:
701 raise GerritError(
702 200, 'Could not get review information for change "%s"' % change)
703 value = str(value)
704 revision = jmsg[0]['current_revision']
705 path = 'changes/%s/revisions/%s/review' % (change, revision)
706 message = message or (
707 '%s label set to %s programmatically.' % (label, value))
708 jmsg = GetReview(host, change, revision)
709 if not jmsg:
710 raise GerritError(200, 'Could not get review information for revison %s '
711 'of change %s' % (revision, change))
712 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
713 if str(review.get('value', value)) != value:
714 body = {
715 'message': message,
716 'labels': {label: value},
717 'on_behalf_of': review['_account_id'],
718 }
719 if notify:
720 body['notify'] = notify
721 conn = CreateHttpConn(
722 host, path, reqtype='POST', body=body)
723 response = ReadHttpJsonResponse(conn)
724 if str(response['labels'][label]) != value:
725 username = review.get('email', jmsg.get('name', ''))
726 raise GerritError(200, 'Unable to set %s label for user "%s"'
727 ' on change %s.' % (label, username, change))
728 jmsg = GetChangeCurrentRevision(host, change)
729 if not jmsg:
730 raise GerritError(
731 200, 'Could not get review information for change "%s"' % change)
732 elif jmsg[0]['current_revision'] != revision:
733 raise GerritError(200, 'While resetting labels on change "%s", '
734 'a new patchset was uploaded.' % change)