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