blob: 0aef46d7b5593b2495636cc10dfdf7f589b3b5e0 [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
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010014import httplib # Still used for its constants.
szager@chromium.orgb4696232013-10-16 19:45:35 +000015import json
16import logging
17import netrc
18import os
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000019import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000020import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000021import stat
22import sys
Dan Jacques8d11e482016-11-15 14:25:56 -080023import tempfile
szager@chromium.orgb4696232013-10-16 19:45:35 +000024import time
25import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000026import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000027from cStringIO import StringIO
28
Dan Jacques8d11e482016-11-15 14:25:56 -080029import gclient_utils
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010030from third_party import httplib2
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
41class GerritError(Exception):
42 """Exception class for errors commuicating with the gerrit-on-borg service."""
43 def __init__(self, http_status, *args, **kwargs):
44 super(GerritError, self).__init__(*args, **kwargs)
45 self.http_status = http_status
46 self.message = '(%d) %s' % (self.http_status, self.message)
47
48
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000049class GerritAuthenticationError(GerritError):
50 """Exception class for authentication errors during Gerrit communication."""
51
52
szager@chromium.orgb4696232013-10-16 19:45:35 +000053def _QueryString(param_dict, first_param=None):
54 """Encodes query parameters in the key:val[+key:val...] format specified here:
55
56 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
57 """
58 q = [urllib.quote(first_param)] if first_param else []
59 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
60 return '+'.join(q)
61
62
Aaron Gabled2db5a22017-03-24 14:14:15 -070063def GetConnectionObject(protocol=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +000064 if protocol is None:
65 protocol = GERRIT_PROTOCOL
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +010066 if protocol in ('http', 'https'):
Aaron Gabled2db5a22017-03-24 14:14:15 -070067 return httplib2.Http()
szager@chromium.orgb4696232013-10-16 19:45:35 +000068 else:
69 raise RuntimeError(
70 "Don't know how to work with protocol '%s'" % protocol)
71
72
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000073class Authenticator(object):
74 """Base authenticator class for authenticator implementations to subclass."""
75
76 def get_auth_header(self, host):
77 raise NotImplementedError()
78
79 @staticmethod
80 def get():
81 """Returns: (Authenticator) The identified Authenticator to use.
82
83 Probes the local system and its environment and identifies the
84 Authenticator instance to use.
85 """
86 if GceAuthenticator.is_gce():
87 return GceAuthenticator()
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000088 return CookiesAuthenticator()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000089
90
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +000091class CookiesAuthenticator(Authenticator):
92 """Authenticator implementation that uses ".netrc" or ".gitcookies" for token.
93
94 Expected case for developer workstations.
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000095 """
96
97 def __init__(self):
98 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000099 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000100
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000101 @classmethod
102 def get_new_password_message(cls, host):
103 assert not host.startswith('http')
104 # Assume *.googlesource.com pattern.
105 parts = host.split('.')
106 if not parts[0].endswith('-review'):
107 parts[0] += '-review'
108 url = 'https://%s/new-password' % ('.'.join(parts))
Andrii Shyshkalov0a0b0672017-03-16 16:27:48 +0100109 return 'You can (re)generate your credentials by visiting %s' % url
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000110
111 @classmethod
112 def get_netrc_path(cls):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000113 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000114 return os.path.expanduser(os.path.join('~', path))
115
116 @classmethod
117 def _get_netrc(cls):
Dan Jacques8d11e482016-11-15 14:25:56 -0800118 # Buffer the '.netrc' path. Use an empty file if it doesn't exist.
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000119 path = cls.get_netrc_path()
Dan Jacques8d11e482016-11-15 14:25:56 -0800120 content = ''
121 if os.path.exists(path):
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000122 st = os.stat(path)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000123 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
124 print >> sys.stderr, (
125 'WARNING: netrc file %s cannot be used because its file '
126 'permissions are insecure. netrc file permissions should be '
127 '600.' % path)
Dan Jacques8d11e482016-11-15 14:25:56 -0800128 with open(path) as fd:
129 content = fd.read()
130
131 # Load the '.netrc' file. We strip comments from it because processing them
132 # can trigger a bug in Windows. See crbug.com/664664.
133 content = '\n'.join(l for l in content.splitlines()
134 if l.strip() and not l.strip().startswith('#'))
135 with tempdir() as tdir:
136 netrc_path = os.path.join(tdir, 'netrc')
137 with open(netrc_path, 'w') as fd:
138 fd.write(content)
139 os.chmod(netrc_path, (stat.S_IRUSR | stat.S_IWUSR))
140 return cls._get_netrc_from_path(netrc_path)
141
142 @classmethod
143 def _get_netrc_from_path(cls, path):
144 try:
145 return netrc.netrc(path)
146 except IOError:
147 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
148 return netrc.netrc(os.devnull)
149 except netrc.NetrcParseError as e:
150 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
151 'parsing error: %s' % (path, e))
152 return netrc.netrc(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):
Ravi Mistry0bfa9ad2016-11-21 12:58:31 -0500156 if os.getenv('GIT_COOKIES_PATH'):
157 return os.getenv('GIT_COOKIES_PATH')
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000158 return os.path.join(os.environ['HOME'], '.gitcookies')
159
160 @classmethod
161 def _get_gitcookies(cls):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000162 gitcookies = {}
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000163 path = cls.get_gitcookies_path()
164 if not os.path.exists(path):
165 return gitcookies
166
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000167 try:
168 f = open(path, 'rb')
169 except IOError:
170 return gitcookies
171
172 with f:
173 for line in f:
174 try:
175 fields = line.strip().split('\t')
176 if line.strip().startswith('#') or len(fields) != 7:
177 continue
178 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
179 if xpath == '/' and key == 'o':
180 login, secret_token = value.split('=', 1)
181 gitcookies[domain] = (login, secret_token)
182 except (IndexError, ValueError, TypeError) as exc:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100183 LOGGER.warning(exc)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000184
185 return gitcookies
186
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100187 def _get_auth_for_host(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000188 for domain, creds in self.gitcookies.iteritems():
189 if cookielib.domain_match(host, domain):
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100190 return (creds[0], None, creds[1])
191 return self.netrc.authenticators(host)
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000192
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100193 def get_auth_header(self, host):
194 auth = self._get_auth_for_host(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000195 if auth:
196 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
197 return None
198
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100199 def get_auth_email(self, host):
200 """Best effort parsing of email to be used for auth for the given host."""
201 auth = self._get_auth_for_host(host)
202 if not auth:
203 return None
204 login = auth[0]
205 # login typically looks like 'git-xxx.example.com'
206 if not login.startswith('git-') or '.' not in login:
207 return None
208 username, domain = login[len('git-'):].split('.', 1)
209 return '%s@%s' % (username, domain)
210
Andrii Shyshkalov18975322017-01-25 16:44:13 +0100211
tandrii@chromium.orgfe30f182016-04-13 12:15:04 +0000212# Backwards compatibility just in case somebody imports this outside of
213# depot_tools.
214NetrcAuthenticator = CookiesAuthenticator
215
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000216
217class GceAuthenticator(Authenticator):
218 """Authenticator implementation that uses GCE metadata service for token.
219 """
220
221 _INFO_URL = 'http://metadata.google.internal'
222 _ACQUIRE_URL = ('http://metadata/computeMetadata/v1/instance/'
223 'service-accounts/default/token')
224 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
225
226 _cache_is_gce = None
227 _token_cache = None
228 _token_expiration = None
229
230 @classmethod
231 def is_gce(cls):
Ravi Mistryfad941b2016-11-15 13:00:47 -0500232 if os.getenv('SKIP_GCE_AUTH_FOR_GIT'):
233 return False
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000234 if cls._cache_is_gce is None:
235 cls._cache_is_gce = cls._test_is_gce()
236 return cls._cache_is_gce
237
238 @classmethod
239 def _test_is_gce(cls):
240 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
241 try:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100242 resp, _ = cls._get(cls._INFO_URL)
243 except (socket.error, httplib2.ServerNotFoundError):
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000244 # Could not resolve URL.
245 return False
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100246 return resp.get('metadata-flavor') == 'Google'
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000247
248 @staticmethod
249 def _get(url, **kwargs):
250 next_delay_sec = 1
251 for i in xrange(TRY_LIMIT):
252 if i > 0:
253 # Retry server error status codes.
254 LOGGER.info('Encountered server error; retrying after %d second(s).',
255 next_delay_sec)
256 time.sleep(next_delay_sec)
257 next_delay_sec *= 2
258
259 p = urlparse.urlparse(url)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700260 c = GetConnectionObject(protocol=p.scheme)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100261 resp, contents = c.request(url, 'GET', **kwargs)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000262 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
263 if resp.status < httplib.INTERNAL_SERVER_ERROR:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100264 return (resp, contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000265
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000266 @classmethod
267 def _get_token_dict(cls):
268 if cls._token_cache:
269 # If it expires within 25 seconds, refresh.
270 if cls._token_expiration < time.time() - 25:
271 return cls._token_cache
272
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100273 resp, contents = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000274 if resp.status != httplib.OK:
275 return None
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100276 cls._token_cache = json.loads(contents)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000277 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
278 return cls._token_cache
279
280 def get_auth_header(self, _host):
281 token_dict = self._get_token_dict()
282 if not token_dict:
283 return None
284 return '%(token_type)s %(access_token)s' % token_dict
285
286
szager@chromium.orgb4696232013-10-16 19:45:35 +0000287def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
288 """Opens an https connection to a gerrit service, and sends a request."""
289 headers = headers or {}
290 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000291
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000292 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000293 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000294 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000295 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000296 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000297
Dan Jacques6d5bcc22016-11-14 15:32:04 -0800298 url = path
299 if not url.startswith('/'):
300 url = '/' + url
301 if 'Authorization' in headers and not url.startswith('/a/'):
302 url = '/a%s' % url
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000303
szager@chromium.orgb4696232013-10-16 19:45:35 +0000304 if body:
305 body = json.JSONEncoder().encode(body)
306 headers.setdefault('Content-Type', 'application/json')
307 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000308 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000309 for key, val in headers.iteritems():
310 if key == 'Authorization':
311 val = 'HIDDEN'
312 LOGGER.debug('%s: %s' % (key, val))
313 if body:
314 LOGGER.debug(body)
Aaron Gabled2db5a22017-03-24 14:14:15 -0700315 conn = GetConnectionObject()
szager@chromium.orgb4696232013-10-16 19:45:35 +0000316 conn.req_host = host
317 conn.req_params = {
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100318 'uri': urlparse.urljoin('%s://%s' % (GERRIT_PROTOCOL, host), url),
szager@chromium.orgb4696232013-10-16 19:45:35 +0000319 'method': reqtype,
320 'headers': headers,
321 'body': body,
322 }
szager@chromium.orgb4696232013-10-16 19:45:35 +0000323 return conn
324
325
Aaron Gable382674b2017-04-18 18:50:18 +0000326def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000327 """Reads an http response from a connection into a string buffer.
328
329 Args:
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100330 conn: An Http object created by CreateHttpConn above.
Aaron Gable382674b2017-04-18 18:50:18 +0000331 expect_status: Success is indicated by this status in the response.
332 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
333 doesn't match the database contents. In most such cases, we
334 want the API to return None rather than raise an Exception.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000335 Returns: A string buffer containing the connection's reply.
336 """
Aaron Gable382674b2017-04-18 18:50:18 +0000337
szager@chromium.orgb4696232013-10-16 19:45:35 +0000338 sleep_time = 0.5
339 for idx in range(TRY_LIMIT):
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100340 response, contents = conn.request(**conn.req_params)
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000341
342 # Check if this is an authentication issue.
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100343 www_authenticate = response.get('www-authenticate')
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000344 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
345 www_authenticate):
346 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
347 host = auth_match.group(1) if auth_match else conn.req_host
Aaron Gable382674b2017-04-18 18:50:18 +0000348 reason = ('Authentication failed. Please make sure your .netrc file '
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000349 'has credentials for %s' % host)
350 raise GerritAuthenticationError(response.status, reason)
351
szager@chromium.orgb4696232013-10-16 19:45:35 +0000352 # If response.status < 500 then the result is final; break retry loop.
353 if response.status < 500:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100354 LOGGER.debug('got response %d for %s %s', response.status,
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100355 conn.req_params['method'], conn.req_params['uri'])
szager@chromium.orgb4696232013-10-16 19:45:35 +0000356 break
357 # A status >=500 is assumed to be a possible transient error; retry.
358 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100359 LOGGER.warn('A transient error occurred while querying %s:\n'
360 '%s %s %s\n'
361 '%s %d %s',
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100362 conn.host, conn.req_params['method'], conn.req_params['uri'],
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100363 http_version, http_version, response.status, response.reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000364 if TRY_LIMIT - idx > 1:
Andrii Shyshkalov5b04a572017-01-23 17:44:41 +0100365 LOGGER.warn('... will retry %d more times.', TRY_LIMIT - idx - 1)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000366 time.sleep(sleep_time)
367 sleep_time = sleep_time * 2
Aaron Gable382674b2017-04-18 18:50:18 +0000368 if ignore_404 and response.status == 404:
369 return StringIO()
370 if response.status != expect_status:
Andrii Shyshkalov4956f792017-04-10 14:28:38 +0200371 if response.status in (401, 403):
372 print('Your Gerrit credentials might be misconfigured. Try: \n'
373 ' git cl creds-check')
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100374 reason = '%s: %s' % (response.reason, contents)
nodir@chromium.orga7798032014-04-30 23:40:53 +0000375 raise GerritError(response.status, reason)
Raphael Kubo da Costa89d04852017-03-23 19:04:31 +0100376 return StringIO(contents)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000377
378
Aaron Gable382674b2017-04-18 18:50:18 +0000379def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000380 """Parses an https response as json."""
Aaron Gable382674b2017-04-18 18:50:18 +0000381 fh = ReadHttpResponse(
382 conn, expect_status=expect_status, ignore_404=ignore_404)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000383 # The first line of the response should always be: )]}'
384 s = fh.readline()
385 if s and s.rstrip() != ")]}'":
386 raise GerritError(200, 'Unexpected json output: %s' % s)
387 s = fh.read()
388 if not s:
389 return None
390 return json.loads(s)
391
392
393def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100394 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000395 """
396 Queries a gerrit-on-borg server for changes matching query terms.
397
398 Args:
399 param_dict: A dictionary of search parameters, as documented here:
400 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
401 first_param: A change identifier
402 limit: Maximum number of results to return.
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100403 start: how many changes to skip (starting with the most recent)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000404 o_params: A list of additional output specifiers, as documented here:
405 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
406 Returns:
407 A list of json-decoded query results.
408 """
409 # Note that no attempt is made to escape special characters; YMMV.
410 if not param_dict and not first_param:
411 raise RuntimeError('QueryChanges requires search parameters')
412 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100413 if start:
414 path = '%s&start=%s' % (path, start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000415 if limit:
416 path = '%s&n=%d' % (path, limit)
417 if o_params:
418 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
419 # Don't ignore 404; a query should always return a list, even if it's empty.
Aaron Gable382674b2017-04-18 18:50:18 +0000420 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000421
422
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000423def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100424 o_params=None, start=None):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000425 """
426 Queries a gerrit-on-borg server for all the changes matching the query terms.
427
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100428 WARNING: this is unreliable if a change matching the query is modified while
429 this function is being called.
430
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000431 A single query to gerrit-on-borg is limited on the number of results by the
432 limit parameter on the request (see QueryChanges) and the server maximum
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100433 limit.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000434
435 Args:
436 param_dict, first_param: Refer to QueryChanges().
437 limit: Maximum number of requested changes per query.
438 o_params: Refer to QueryChanges().
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100439 start: Refer to QueryChanges().
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000440
441 Returns:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100442 A generator object to the list of returned changes.
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000443 """
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100444 already_returned = set()
445 def at_most_once(cls):
446 for cl in cls:
447 if cl['_number'] not in already_returned:
448 already_returned.add(cl['_number'])
449 yield cl
450
451 start = start or 0
452 cur_start = start
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000453 more_changes = True
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100454
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000455 while more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100456 # This will fetch changes[start..start+limit] sorted by most recently
457 # updated. Since the rank of any change in this list can be changed any time
458 # (say user posting comment), subsequent calls may overalp like this:
459 # > initial order ABCDEFGH
460 # query[0..3] => ABC
461 # > E get's updated. New order: EABCDFGH
462 # query[3..6] => CDF # C is a dup
463 # query[6..9] => GH # E is missed.
464 page = QueryChanges(host, param_dict, first_param, limit, o_params,
465 cur_start)
466 for cl in at_most_once(page):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000467 yield cl
468
469 more_changes = [cl for cl in page if '_more_changes' in cl]
470 if len(more_changes) > 1:
471 raise GerritError(
472 200,
473 'Received %d changes with a _more_changes attribute set but should '
474 'receive at most one.' % len(more_changes))
475 if more_changes:
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100476 cur_start += len(page)
477
478 # If we paged through, query again the first page which in most circumstances
479 # will fetch all changes that were modified while this function was run.
480 if start != cur_start:
481 page = QueryChanges(host, param_dict, first_param, limit, o_params, start)
482 for cl in at_most_once(page):
483 yield cl
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000484
485
szager@chromium.orgb4696232013-10-16 19:45:35 +0000486def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100487 start=None):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000488 """Initiate a query composed of multiple sets of query parameters."""
489 if not change_list:
490 raise RuntimeError(
491 "MultiQueryChanges requires a list of change numbers/id's")
492 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
493 if param_dict:
494 q.append(_QueryString(param_dict))
495 if limit:
496 q.append('n=%d' % limit)
Andrii Shyshkalov892e9c22017-03-08 16:21:21 +0100497 if start:
498 q.append('S=%s' % start)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000499 if o_params:
500 q.extend(['o=%s' % p for p in o_params])
501 path = 'changes/?%s' % '&'.join(q)
502 try:
Aaron Gable382674b2017-04-18 18:50:18 +0000503 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000504 except GerritError as e:
505 msg = '%s:\n%s' % (e.message, path)
506 raise GerritError(e.http_status, msg)
507 return result
508
509
510def GetGerritFetchUrl(host):
511 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
512 return '%s://%s/' % (GERRIT_PROTOCOL, host)
513
514
515def GetChangePageUrl(host, change_number):
516 """Given a gerrit host name and change number, return change page url."""
517 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
518
519
520def GetChangeUrl(host, change):
521 """Given a gerrit host name and change id, return an url for the change."""
522 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
523
524
525def GetChange(host, change):
526 """Query a gerrit server for information about a single change."""
527 path = 'changes/%s' % change
528 return ReadHttpJsonResponse(CreateHttpConn(host, path))
529
530
Aaron Gable382674b2017-04-18 18:50:18 +0000531def GetChangeDetail(host, change, o_params=None, ignore_404=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000532 """Query a gerrit server for extended information about a single change."""
Aaron Gable382674b2017-04-18 18:50:18 +0000533 # TODO(tandrii): cahnge ignore_404 to False by default.
szager@chromium.orgb4696232013-10-16 19:45:35 +0000534 path = 'changes/%s/detail' % change
535 if o_params:
536 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
Aaron Gable382674b2017-04-18 18:50:18 +0000537 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=ignore_404)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000538
539
agable32978d92016-11-01 12:55:02 -0700540def GetChangeCommit(host, change, revision='current'):
541 """Query a gerrit server for a revision associated with a change."""
542 path = 'changes/%s/revisions/%s/commit?links' % (change, revision)
543 return ReadHttpJsonResponse(CreateHttpConn(host, path))
544
545
szager@chromium.orgb4696232013-10-16 19:45:35 +0000546def GetChangeCurrentRevision(host, change):
547 """Get information about the latest revision for a given change."""
548 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
549
550
551def GetChangeRevisions(host, change):
552 """Get information about all revisions associated with a change."""
553 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
554
555
556def GetChangeReview(host, change, revision=None):
557 """Get the current review information for a change."""
558 if not revision:
559 jmsg = GetChangeRevisions(host, change)
560 if not jmsg:
561 return None
562 elif len(jmsg) > 1:
563 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
564 revision = jmsg[0]['current_revision']
565 path = 'changes/%s/revisions/%s/review'
566 return ReadHttpJsonResponse(CreateHttpConn(host, path))
567
568
569def AbandonChange(host, change, msg=''):
570 """Abandon a gerrit change."""
571 path = 'changes/%s/abandon' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000572 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000573 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable382674b2017-04-18 18:50:18 +0000574 return ReadHttpJsonResponse(conn, ignore_404=False)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000575
576
577def RestoreChange(host, change, msg=''):
578 """Restore a previously abandoned change."""
579 path = 'changes/%s/restore' % change
tandrii@chromium.orgc7da66a2016-03-24 09:52:24 +0000580 body = {'message': msg} if msg else {}
szager@chromium.orgb4696232013-10-16 19:45:35 +0000581 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable382674b2017-04-18 18:50:18 +0000582 return ReadHttpJsonResponse(conn, ignore_404=False)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000583
584
585def SubmitChange(host, change, wait_for_merge=True):
586 """Submits a gerrit change via Gerrit."""
587 path = 'changes/%s/submit' % change
588 body = {'wait_for_merge': wait_for_merge}
589 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable382674b2017-04-18 18:50:18 +0000590 return ReadHttpJsonResponse(conn, ignore_404=False)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000591
592
dsansomee2d6fd92016-09-08 00:10:47 -0700593def HasPendingChangeEdit(host, change):
594 conn = CreateHttpConn(host, 'changes/%s/edit' % change)
595 try:
Aaron Gable382674b2017-04-18 18:50:18 +0000596 ReadHttpResponse(conn, ignore_404=False)
dsansomee2d6fd92016-09-08 00:10:47 -0700597 except GerritError as e:
Aaron Gable382674b2017-04-18 18:50:18 +0000598 # On success, gerrit returns status 204; anything else is an error.
599 if e.http_status != 204:
600 raise
601 return False
602 else:
603 return True
dsansomee2d6fd92016-09-08 00:10:47 -0700604
605
606def DeletePendingChangeEdit(host, change):
607 conn = CreateHttpConn(host, 'changes/%s/edit' % change, reqtype='DELETE')
Aaron Gable382674b2017-04-18 18:50:18 +0000608 try:
609 ReadHttpResponse(conn, ignore_404=False)
610 except GerritError as e:
611 # On success, gerrit returns status 204; if the edit was already deleted it
612 # returns 404. Anything else is an error.
613 if e.http_status not in (204, 404):
614 raise
dsansomee2d6fd92016-09-08 00:10:47 -0700615
616
Andrii Shyshkalovea4fc832016-12-01 14:53:23 +0100617def SetCommitMessage(host, change, description, notify='ALL'):
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000618 """Updates a commit message."""
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000619 try:
Aaron Gablee9373d62016-12-13 12:28:45 -0800620 assert notify in ('ALL', 'NONE')
621 # First, edit the commit message in a draft.
622 path = 'changes/%s/edit:message' % change
623 body = {'message': description}
624 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
625 try:
Aaron Gable382674b2017-04-18 18:50:18 +0000626 ReadHttpResponse(conn, ignore_404=False)
Aaron Gablee9373d62016-12-13 12:28:45 -0800627 except GerritError as e:
Aaron Gable382674b2017-04-18 18:50:18 +0000628 # On success, gerrit returns status 204; anything else is an error.
629 if e.http_status != 204:
630 raise
631 else:
Aaron Gablee9373d62016-12-13 12:28:45 -0800632 raise GerritError(
Aaron Gable382674b2017-04-18 18:50:18 +0000633 'Unexpectedly received a 200 http status while editing message in '
634 'change %s' % change)
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000635
Aaron Gablee9373d62016-12-13 12:28:45 -0800636 # And then publish it.
637 path = 'changes/%s/edit:publish' % change
638 conn = CreateHttpConn(host, path, reqtype='POST', body={'notify': notify})
639 try:
Aaron Gable382674b2017-04-18 18:50:18 +0000640 ReadHttpResponse(conn, ignore_404=False)
Aaron Gablee9373d62016-12-13 12:28:45 -0800641 except GerritError as e:
Aaron Gable382674b2017-04-18 18:50:18 +0000642 # On success, gerrit returns status 204; anything else is an error.
643 if e.http_status != 204:
644 raise
645 else:
Aaron Gablee9373d62016-12-13 12:28:45 -0800646 raise GerritError(
Aaron Gable382674b2017-04-18 18:50:18 +0000647 'Unexpectedly received a 200 http status while publishing message '
648 'change in %s' % change)
Aaron Gablee9373d62016-12-13 12:28:45 -0800649 except (GerritError, KeyboardInterrupt) as e:
650 # Something went wrong with one of the two calls, so we want to clean up
651 # after ourselves before returning.
652 try:
653 DeletePendingChangeEdit(host, change)
654 except GerritError:
655 LOGGER.error('Encountered error while cleaning up after failed attempt '
656 'to set the CL description. You may have to delete the '
657 'pending change edit yourself in the web UI.')
658 raise e
scottmg@chromium.org6d1266e2016-04-26 11:12:26 +0000659
660
szager@chromium.orgb4696232013-10-16 19:45:35 +0000661def GetReviewers(host, change):
662 """Get information about all reviewers attached to a change."""
663 path = 'changes/%s/reviewers' % change
664 return ReadHttpJsonResponse(CreateHttpConn(host, path))
665
666
667def GetReview(host, change, revision):
668 """Get review information about a specific revision of a change."""
669 path = 'changes/%s/revisions/%s/review' % (change, revision)
670 return ReadHttpJsonResponse(CreateHttpConn(host, path))
671
672
Aaron Gable59f48512017-01-12 10:54:46 -0800673def AddReviewers(host, change, add=None, is_reviewer=True, notify=True):
szager@chromium.orgb4696232013-10-16 19:45:35 +0000674 """Add reviewers to a change."""
Aaron Gabledf86e302016-11-08 10:48:03 -0800675 errors = None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000676 if not add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800677 return None
szager@chromium.orgb4696232013-10-16 19:45:35 +0000678 if isinstance(add, basestring):
679 add = (add,)
680 path = 'changes/%s/reviewers' % change
681 for r in add:
Aaron Gabledf86e302016-11-08 10:48:03 -0800682 state = 'REVIEWER' if is_reviewer else 'CC'
Aaron Gable59f48512017-01-12 10:54:46 -0800683 notify = 'ALL' if notify else 'NONE'
tandrii88189772016-09-29 04:29:57 -0700684 body = {
685 'reviewer': r,
Aaron Gabledf86e302016-11-08 10:48:03 -0800686 'state': state,
Aaron Gable59f48512017-01-12 10:54:46 -0800687 'notify': notify,
tandrii88189772016-09-29 04:29:57 -0700688 }
Aaron Gabledf86e302016-11-08 10:48:03 -0800689 try:
690 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
Aaron Gable382674b2017-04-18 18:50:18 +0000691 _ = ReadHttpJsonResponse(conn, ignore_404=False)
Aaron Gabledf86e302016-11-08 10:48:03 -0800692 except GerritError as e:
693 if e.http_status == 422: # "Unprocessable Entity"
Aaron Gableb7cb65a2017-03-14 11:39:41 -0700694 LOGGER.warn('Note: "%s" not added as a %s' % (r, state.lower()))
Aaron Gabledf86e302016-11-08 10:48:03 -0800695 errors = True
696 else:
697 raise
698 return errors
szager@chromium.orgb4696232013-10-16 19:45:35 +0000699
700
701def RemoveReviewers(host, change, remove=None):
702 """Remove reveiewers from a change."""
703 if not remove:
704 return
705 if isinstance(remove, basestring):
706 remove = (remove,)
707 for r in remove:
708 path = 'changes/%s/reviewers/%s' % (change, r)
709 conn = CreateHttpConn(host, path, reqtype='DELETE')
710 try:
Aaron Gable382674b2017-04-18 18:50:18 +0000711 ReadHttpResponse(conn, ignore_404=False)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000712 except GerritError as e:
Aaron Gable382674b2017-04-18 18:50:18 +0000713 # On success, gerrit returns status 204; anything else is an error.
714 if e.http_status != 204:
715 raise
716 else:
szager@chromium.orgb4696232013-10-16 19:45:35 +0000717 raise GerritError(
Aaron Gable382674b2017-04-18 18:50:18 +0000718 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
719 ' from change %s' % (r, change))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000720
721
722def SetReview(host, change, msg=None, labels=None, notify=None):
723 """Set labels and/or add a message to a code review."""
724 if not msg and not labels:
725 return
726 path = 'changes/%s/revisions/current/review' % change
727 body = {}
728 if msg:
729 body['message'] = msg
730 if labels:
731 body['labels'] = labels
732 if notify:
733 body['notify'] = notify
734 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
735 response = ReadHttpJsonResponse(conn)
736 if labels:
737 for key, val in labels.iteritems():
738 if ('labels' not in response or key not in response['labels'] or
739 int(response['labels'][key] != int(val))):
740 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
741 key, change))
742
743
744def ResetReviewLabels(host, change, label, value='0', message=None,
745 notify=None):
746 """Reset the value of a given label for all reviewers on a change."""
747 # This is tricky, because we want to work on the "current revision", but
748 # there's always the risk that "current revision" will change in between
749 # API calls. So, we check "current revision" at the beginning and end; if
750 # it has changed, raise an exception.
751 jmsg = GetChangeCurrentRevision(host, change)
752 if not jmsg:
753 raise GerritError(
754 200, 'Could not get review information for change "%s"' % change)
755 value = str(value)
756 revision = jmsg[0]['current_revision']
757 path = 'changes/%s/revisions/%s/review' % (change, revision)
758 message = message or (
759 '%s label set to %s programmatically.' % (label, value))
760 jmsg = GetReview(host, change, revision)
761 if not jmsg:
762 raise GerritError(200, 'Could not get review information for revison %s '
763 'of change %s' % (revision, change))
764 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
765 if str(review.get('value', value)) != value:
766 body = {
767 'message': message,
768 'labels': {label: value},
769 'on_behalf_of': review['_account_id'],
770 }
771 if notify:
772 body['notify'] = notify
773 conn = CreateHttpConn(
774 host, path, reqtype='POST', body=body)
775 response = ReadHttpJsonResponse(conn)
776 if str(response['labels'][label]) != value:
777 username = review.get('email', jmsg.get('name', ''))
778 raise GerritError(200, 'Unable to set %s label for user "%s"'
779 ' on change %s.' % (label, username, change))
780 jmsg = GetChangeCurrentRevision(host, change)
781 if not jmsg:
782 raise GerritError(
783 200, 'Could not get review information for change "%s"' % change)
784 elif jmsg[0]['current_revision'] != revision:
785 raise GerritError(200, 'While resetting labels on change "%s", '
786 'a new patchset was uploaded.' % change)
Dan Jacques8d11e482016-11-15 14:25:56 -0800787
788
dimu833c94c2017-01-18 17:36:15 -0800789def CreateGerritBranch(host, project, branch, commit):
790 """
791 Create a new branch from given project and commit
792 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-branch
793
794 Returns:
795 A JSON with 'ref' key
796 """
797 path = 'projects/%s/branches/%s' % (project, branch)
798 body = {'revision': commit}
799 conn = CreateHttpConn(host, path, reqtype='PUT', body=body)
800 response = ReadHttpJsonResponse(conn)
801 if response:
802 return response
803 raise GerritError(200, 'Unable to create gerrit branch')
804
805
806def GetGerritBranch(host, project, branch):
807 """
808 Get a branch from given project and commit
809 https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch
810
811 Returns:
812 A JSON object with 'revision' key
813 """
814 path = 'projects/%s/branches/%s' % (project, branch)
815 conn = CreateHttpConn(host, path, reqtype='GET')
816 response = ReadHttpJsonResponse(conn)
817 if response:
818 return response
819 raise GerritError(200, 'Unable to get gerrit branch')
820
821
Andrii Shyshkalovbb86fbb2017-03-24 14:59:28 +0100822def GetAccountDetails(host, account_id='self'):
823 """Returns details of the account.
824
825 If account_id is not given, uses magic value 'self' which corresponds to
826 whichever account user is authenticating as.
827
828 Documentation:
829 https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account
830 """
831 if account_id != 'self':
832 account_id = int(account_id)
833 conn = CreateHttpConn(host, '/accounts/%s' % account_id)
834 return ReadHttpJsonResponse(conn)
835
836
Dan Jacques8d11e482016-11-15 14:25:56 -0800837@contextlib.contextmanager
838def tempdir():
839 tdir = None
840 try:
841 tdir = tempfile.mkdtemp(suffix='gerrit_util')
842 yield tdir
843 finally:
844 if tdir:
845 gclient_utils.rmtree(tdir)