blob: 62fc1b9ab53cac7ddbf60f3f1dc70fae8ba93afd [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
12import httplib
13import json
14import logging
15import netrc
16import os
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000017import re
szager@chromium.orgf202a252014-05-27 18:55:52 +000018import stat
19import sys
szager@chromium.orgb4696232013-10-16 19:45:35 +000020import time
21import urllib
22from cStringIO import StringIO
23
szager@chromium.orgf202a252014-05-27 18:55:52 +000024_netrc_file = '_netrc' if sys.platform.startswith('win') else '.netrc'
25_netrc_file = os.path.join(os.environ['HOME'], _netrc_file)
szager@chromium.orgb4696232013-10-16 19:45:35 +000026try:
szager@chromium.orgf202a252014-05-27 18:55:52 +000027 NETRC = netrc.netrc(_netrc_file)
28except IOError:
29 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % _netrc_file
szager@chromium.orgb4696232013-10-16 19:45:35 +000030 NETRC = netrc.netrc(os.devnull)
szager@chromium.orgf202a252014-05-27 18:55:52 +000031except netrc.NetrcParseError as e:
32 _netrc_stat = os.stat(e.filename)
33 if _netrc_stat.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
34 print >> sys.stderr, (
35 'WARNING: netrc file %s cannot be used because its file permissions '
36 'are insecure. netrc file permissions should be 600.' % _netrc_file)
37 else:
38 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a parsing '
39 'error.' % _netrc_file)
40 raise
41 del _netrc_stat
42 NETRC = netrc.netrc(os.devnull)
43del _netrc_file
44
szager@chromium.orgb4696232013-10-16 19:45:35 +000045LOGGER = logging.getLogger()
46TRY_LIMIT = 5
47
48# Controls the transport protocol used to communicate with gerrit.
49# This is parameterized primarily to enable GerritTestCase.
50GERRIT_PROTOCOL = 'https'
51
52
53class GerritError(Exception):
54 """Exception class for errors commuicating with the gerrit-on-borg service."""
55 def __init__(self, http_status, *args, **kwargs):
56 super(GerritError, self).__init__(*args, **kwargs)
57 self.http_status = http_status
58 self.message = '(%d) %s' % (self.http_status, self.message)
59
60
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000061class GerritAuthenticationError(GerritError):
62 """Exception class for authentication errors during Gerrit communication."""
63
64
szager@chromium.orgb4696232013-10-16 19:45:35 +000065def _QueryString(param_dict, first_param=None):
66 """Encodes query parameters in the key:val[+key:val...] format specified here:
67
68 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
69 """
70 q = [urllib.quote(first_param)] if first_param else []
71 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
72 return '+'.join(q)
73
74
75def GetConnectionClass(protocol=None):
76 if protocol is None:
77 protocol = GERRIT_PROTOCOL
78 if protocol == 'https':
79 return httplib.HTTPSConnection
80 elif protocol == 'http':
81 return httplib.HTTPConnection
82 else:
83 raise RuntimeError(
84 "Don't know how to work with protocol '%s'" % protocol)
85
86
87def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
88 """Opens an https connection to a gerrit service, and sends a request."""
89 headers = headers or {}
90 bare_host = host.partition(':')[0]
91 auth = NETRC.authenticators(bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000092
szager@chromium.orgb4696232013-10-16 19:45:35 +000093 if auth:
94 headers.setdefault('Authorization', 'Basic %s' % (
95 base64.b64encode('%s:%s' % (auth[0], auth[2]))))
96 else:
nodir@chromium.org52595082014-05-22 22:18:16 +000097 LOGGER.debug('No authorization found in netrc for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000098
99 if 'Authorization' in headers and not path.startswith('a/'):
100 url = '/a/%s' % path
101 else:
102 url = '/%s' % path
103
szager@chromium.orgb4696232013-10-16 19:45:35 +0000104 if body:
105 body = json.JSONEncoder().encode(body)
106 headers.setdefault('Content-Type', 'application/json')
107 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000108 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000109 for key, val in headers.iteritems():
110 if key == 'Authorization':
111 val = 'HIDDEN'
112 LOGGER.debug('%s: %s' % (key, val))
113 if body:
114 LOGGER.debug(body)
115 conn = GetConnectionClass()(host)
116 conn.req_host = host
117 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000118 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000119 'method': reqtype,
120 'headers': headers,
121 'body': body,
122 }
123 conn.request(**conn.req_params)
124 return conn
125
126
127def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
128 """Reads an http response from a connection into a string buffer.
129
130 Args:
131 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
132 expect_status: Success is indicated by this status in the response.
133 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
134 doesn't match the database contents. In most such cases, we
135 want the API to return None rather than raise an Exception.
136 Returns: A string buffer containing the connection's reply.
137 """
138
139 sleep_time = 0.5
140 for idx in range(TRY_LIMIT):
141 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000142
143 # Check if this is an authentication issue.
144 www_authenticate = response.getheader('www-authenticate')
145 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
146 www_authenticate):
147 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
148 host = auth_match.group(1) if auth_match else conn.req_host
149 reason = ('Authentication failed. Please make sure your .netrc file '
150 'has credentials for %s' % host)
151 raise GerritAuthenticationError(response.status, reason)
152
szager@chromium.orgb4696232013-10-16 19:45:35 +0000153 # If response.status < 500 then the result is final; break retry loop.
154 if response.status < 500:
155 break
156 # A status >=500 is assumed to be a possible transient error; retry.
157 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
158 msg = (
159 'A transient error occured while querying %s:\n'
160 '%s %s %s\n'
161 '%s %d %s' % (
162 conn.host, conn.req_params['method'], conn.req_params['url'],
163 http_version, http_version, response.status, response.reason))
164 if TRY_LIMIT - idx > 1:
165 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
166 time.sleep(sleep_time)
167 sleep_time = sleep_time * 2
168 req_host = conn.req_host
169 req_params = conn.req_params
170 conn = GetConnectionClass()(req_host)
171 conn.req_host = req_host
172 conn.req_params = req_params
173 conn.request(**req_params)
174 LOGGER.warn(msg)
175 if ignore_404 and response.status == 404:
176 return StringIO()
177 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000178 reason = '%s: %s' % (response.reason, response.read())
179 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000180 return StringIO(response.read())
181
182
183def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
184 """Parses an https response as json."""
185 fh = ReadHttpResponse(
186 conn, expect_status=expect_status, ignore_404=ignore_404)
187 # The first line of the response should always be: )]}'
188 s = fh.readline()
189 if s and s.rstrip() != ")]}'":
190 raise GerritError(200, 'Unexpected json output: %s' % s)
191 s = fh.read()
192 if not s:
193 return None
194 return json.loads(s)
195
196
197def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
198 sortkey=None):
199 """
200 Queries a gerrit-on-borg server for changes matching query terms.
201
202 Args:
203 param_dict: A dictionary of search parameters, as documented here:
204 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
205 first_param: A change identifier
206 limit: Maximum number of results to return.
207 o_params: A list of additional output specifiers, as documented here:
208 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
209 Returns:
210 A list of json-decoded query results.
211 """
212 # Note that no attempt is made to escape special characters; YMMV.
213 if not param_dict and not first_param:
214 raise RuntimeError('QueryChanges requires search parameters')
215 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
216 if sortkey:
217 path = '%s&N=%s' % (path, sortkey)
218 if limit:
219 path = '%s&n=%d' % (path, limit)
220 if o_params:
221 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
222 # Don't ignore 404; a query should always return a list, even if it's empty.
223 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
224
225
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000226def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
227 o_params=None, sortkey=None):
228 """
229 Queries a gerrit-on-borg server for all the changes matching the query terms.
230
231 A single query to gerrit-on-borg is limited on the number of results by the
232 limit parameter on the request (see QueryChanges) and the server maximum
233 limit. This function uses the "_more_changes" and "_sortkey" attributes on
234 the returned changes to iterate all of them making multiple queries to the
235 server, regardless the query limit.
236
237 Args:
238 param_dict, first_param: Refer to QueryChanges().
239 limit: Maximum number of requested changes per query.
240 o_params: Refer to QueryChanges().
241 sortkey: The value of the "_sortkey" attribute where starts from. None to
242 start from the first change.
243
244 Returns:
245 A generator object to the list of returned changes, possibly unbound.
246 """
247 more_changes = True
248 while more_changes:
249 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
250 for cl in page:
251 yield cl
252
253 more_changes = [cl for cl in page if '_more_changes' in cl]
254 if len(more_changes) > 1:
255 raise GerritError(
256 200,
257 'Received %d changes with a _more_changes attribute set but should '
258 'receive at most one.' % len(more_changes))
259 if more_changes:
260 sortkey = more_changes[0]['_sortkey']
261
262
szager@chromium.orgb4696232013-10-16 19:45:35 +0000263def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
264 sortkey=None):
265 """Initiate a query composed of multiple sets of query parameters."""
266 if not change_list:
267 raise RuntimeError(
268 "MultiQueryChanges requires a list of change numbers/id's")
269 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
270 if param_dict:
271 q.append(_QueryString(param_dict))
272 if limit:
273 q.append('n=%d' % limit)
274 if sortkey:
275 q.append('N=%s' % sortkey)
276 if o_params:
277 q.extend(['o=%s' % p for p in o_params])
278 path = 'changes/?%s' % '&'.join(q)
279 try:
280 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
281 except GerritError as e:
282 msg = '%s:\n%s' % (e.message, path)
283 raise GerritError(e.http_status, msg)
284 return result
285
286
287def GetGerritFetchUrl(host):
288 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
289 return '%s://%s/' % (GERRIT_PROTOCOL, host)
290
291
292def GetChangePageUrl(host, change_number):
293 """Given a gerrit host name and change number, return change page url."""
294 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
295
296
297def GetChangeUrl(host, change):
298 """Given a gerrit host name and change id, return an url for the change."""
299 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
300
301
302def GetChange(host, change):
303 """Query a gerrit server for information about a single change."""
304 path = 'changes/%s' % change
305 return ReadHttpJsonResponse(CreateHttpConn(host, path))
306
307
308def GetChangeDetail(host, change, o_params=None):
309 """Query a gerrit server for extended information about a single change."""
310 path = 'changes/%s/detail' % change
311 if o_params:
312 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
313 return ReadHttpJsonResponse(CreateHttpConn(host, path))
314
315
316def GetChangeCurrentRevision(host, change):
317 """Get information about the latest revision for a given change."""
318 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
319
320
321def GetChangeRevisions(host, change):
322 """Get information about all revisions associated with a change."""
323 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
324
325
326def GetChangeReview(host, change, revision=None):
327 """Get the current review information for a change."""
328 if not revision:
329 jmsg = GetChangeRevisions(host, change)
330 if not jmsg:
331 return None
332 elif len(jmsg) > 1:
333 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
334 revision = jmsg[0]['current_revision']
335 path = 'changes/%s/revisions/%s/review'
336 return ReadHttpJsonResponse(CreateHttpConn(host, path))
337
338
339def AbandonChange(host, change, msg=''):
340 """Abandon a gerrit change."""
341 path = 'changes/%s/abandon' % change
342 body = {'message': msg} if msg else None
343 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
344 return ReadHttpJsonResponse(conn, ignore_404=False)
345
346
347def RestoreChange(host, change, msg=''):
348 """Restore a previously abandoned change."""
349 path = 'changes/%s/restore' % change
350 body = {'message': msg} if msg else None
351 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
352 return ReadHttpJsonResponse(conn, ignore_404=False)
353
354
355def SubmitChange(host, change, wait_for_merge=True):
356 """Submits a gerrit change via Gerrit."""
357 path = 'changes/%s/submit' % change
358 body = {'wait_for_merge': wait_for_merge}
359 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
360 return ReadHttpJsonResponse(conn, ignore_404=False)
361
362
363def GetReviewers(host, change):
364 """Get information about all reviewers attached to a change."""
365 path = 'changes/%s/reviewers' % change
366 return ReadHttpJsonResponse(CreateHttpConn(host, path))
367
368
369def GetReview(host, change, revision):
370 """Get review information about a specific revision of a change."""
371 path = 'changes/%s/revisions/%s/review' % (change, revision)
372 return ReadHttpJsonResponse(CreateHttpConn(host, path))
373
374
375def AddReviewers(host, change, add=None):
376 """Add reviewers to a change."""
377 if not add:
378 return
379 if isinstance(add, basestring):
380 add = (add,)
381 path = 'changes/%s/reviewers' % change
382 for r in add:
383 body = {'reviewer': r}
384 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
385 jmsg = ReadHttpJsonResponse(conn, ignore_404=False)
386 return jmsg
387
388
389def RemoveReviewers(host, change, remove=None):
390 """Remove reveiewers from a change."""
391 if not remove:
392 return
393 if isinstance(remove, basestring):
394 remove = (remove,)
395 for r in remove:
396 path = 'changes/%s/reviewers/%s' % (change, r)
397 conn = CreateHttpConn(host, path, reqtype='DELETE')
398 try:
399 ReadHttpResponse(conn, ignore_404=False)
400 except GerritError as e:
401 # On success, gerrit returns status 204; anything else is an error.
402 if e.http_status != 204:
403 raise
404 else:
405 raise GerritError(
406 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
407 ' from change %s' % (r, change))
408
409
410def SetReview(host, change, msg=None, labels=None, notify=None):
411 """Set labels and/or add a message to a code review."""
412 if not msg and not labels:
413 return
414 path = 'changes/%s/revisions/current/review' % change
415 body = {}
416 if msg:
417 body['message'] = msg
418 if labels:
419 body['labels'] = labels
420 if notify:
421 body['notify'] = notify
422 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
423 response = ReadHttpJsonResponse(conn)
424 if labels:
425 for key, val in labels.iteritems():
426 if ('labels' not in response or key not in response['labels'] or
427 int(response['labels'][key] != int(val))):
428 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
429 key, change))
430
431
432def ResetReviewLabels(host, change, label, value='0', message=None,
433 notify=None):
434 """Reset the value of a given label for all reviewers on a change."""
435 # This is tricky, because we want to work on the "current revision", but
436 # there's always the risk that "current revision" will change in between
437 # API calls. So, we check "current revision" at the beginning and end; if
438 # it has changed, raise an exception.
439 jmsg = GetChangeCurrentRevision(host, change)
440 if not jmsg:
441 raise GerritError(
442 200, 'Could not get review information for change "%s"' % change)
443 value = str(value)
444 revision = jmsg[0]['current_revision']
445 path = 'changes/%s/revisions/%s/review' % (change, revision)
446 message = message or (
447 '%s label set to %s programmatically.' % (label, value))
448 jmsg = GetReview(host, change, revision)
449 if not jmsg:
450 raise GerritError(200, 'Could not get review information for revison %s '
451 'of change %s' % (revision, change))
452 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
453 if str(review.get('value', value)) != value:
454 body = {
455 'message': message,
456 'labels': {label: value},
457 'on_behalf_of': review['_account_id'],
458 }
459 if notify:
460 body['notify'] = notify
461 conn = CreateHttpConn(
462 host, path, reqtype='POST', body=body)
463 response = ReadHttpJsonResponse(conn)
464 if str(response['labels'][label]) != value:
465 username = review.get('email', jmsg.get('name', ''))
466 raise GerritError(200, 'Unable to set %s label for user "%s"'
467 ' on change %s.' % (label, username, change))
468 jmsg = GetChangeCurrentRevision(host, change)
469 if not jmsg:
470 raise GerritError(
471 200, 'Could not get review information for change "%s"' % change)
472 elif jmsg[0]['current_revision'] != revision:
473 raise GerritError(200, 'While resetting labels on change "%s", '
474 'a new patchset was uploaded.' % change)