1 # 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.
6 Utilities for requesting information for a gerrit server via https.
8 https://gerrit-review.googlesource.com/Documentation/rest-api.html
22 from cStringIO import StringIO
24 _netrc_file = '_netrc' if sys.platform.startswith('win') else '.netrc'
25 _netrc_file = os.path.join(os.environ['HOME'], _netrc_file)
27 NETRC = netrc.netrc(_netrc_file)
29 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % _netrc_file
30 NETRC = netrc.netrc(os.devnull)
31 except 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)
38 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a parsing '
39 'error.' % _netrc_file)
42 NETRC = netrc.netrc(os.devnull)
45 LOGGER = logging.getLogger()
48 # Controls the transport protocol used to communicate with gerrit.
49 # This is parameterized primarily to enable GerritTestCase.
50 GERRIT_PROTOCOL = 'https'
53 class 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)
61 class GerritAuthenticationError(GerritError):
62 """Exception class for authentication errors during Gerrit communication."""
65 def _QueryString(param_dict, first_param=None):
66 """Encodes query parameters in the key:val[+key:val...] format specified here:
68 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
70 q = [urllib.quote(first_param)] if first_param else []
71 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
75 def GetConnectionClass(protocol=None):
77 protocol = GERRIT_PROTOCOL
78 if protocol == 'https':
79 return httplib.HTTPSConnection
80 elif protocol == 'http':
81 return httplib.HTTPConnection
84 "Don't know how to work with protocol '%s'" % protocol)
87 def 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)
94 headers.setdefault('Authorization', 'Basic %s' % (
95 base64.b64encode('%s:%s' % (auth[0], auth[2]))))
97 LOGGER.debug('No authorization found in netrc for %s.' % bare_host)
99 if 'Authorization' in headers and not path.startswith('a/'):
105 body = json.JSONEncoder().encode(body)
106 headers.setdefault('Content-Type', 'application/json')
107 if LOGGER.isEnabledFor(logging.DEBUG):
108 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
109 for key, val in headers.iteritems():
110 if key == 'Authorization':
112 LOGGER.debug('%s: %s' % (key, val))
115 conn = GetConnectionClass()(host)
123 conn.request(**conn.req_params)
127 def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
128 """Reads an http response from a connection into a string buffer.
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.
140 for idx in range(TRY_LIMIT):
141 response = conn.getresponse()
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
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)
153 # If response.status < 500 then the result is final; break retry loop.
154 if response.status < 500:
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')
159 'A transient error occured while querying %s:\n'
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)
175 if ignore_404 and response.status == 404:
177 if response.status != expect_status:
178 reason = '%s: %s' % (response.reason, response.read())
179 raise GerritError(response.status, reason)
180 return StringIO(response.read())
183 def 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: )]}'
189 if s and s.rstrip() != ")]}'":
190 raise GerritError(200, 'Unexpected json output: %s' % s)
197 def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
200 Queries a gerrit-on-borg server for changes matching query terms.
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
210 A list of json-decoded query results.
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)
217 path = '%s&N=%s' % (path, sortkey)
219 path = '%s&n=%d' % (path, limit)
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)
226 def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
227 o_params=None, sortkey=None):
229 Queries a gerrit-on-borg server for all the changes matching the query terms.
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.
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.
245 A generator object to the list of returned changes, possibly unbound.
249 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
253 more_changes = [cl for cl in page if '_more_changes' in cl]
254 if len(more_changes) > 1:
257 'Received %d changes with a _more_changes attribute set but should '
258 'receive at most one.' % len(more_changes))
260 sortkey = more_changes[0]['_sortkey']
263 def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
265 """Initiate a query composed of multiple sets of query parameters."""
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])]
271 q.append(_QueryString(param_dict))
273 q.append('n=%d' % limit)
275 q.append('N=%s' % sortkey)
277 q.extend(['o=%s' % p for p in o_params])
278 path = 'changes/?%s' % '&'.join(q)
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)
287 def GetGerritFetchUrl(host):
288 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
289 return '%s://%s/' % (GERRIT_PROTOCOL, host)
292 def 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)
297 def 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)
302 def 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))
308 def GetChangeDetail(host, change, o_params=None):
309 """Query a gerrit server for extended information about a single change."""
310 path = 'changes/%s/detail' % change
312 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
313 return ReadHttpJsonResponse(CreateHttpConn(host, path))
316 def GetChangeCurrentRevision(host, change):
317 """Get information about the latest revision for a given change."""
318 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
321 def GetChangeRevisions(host, change):
322 """Get information about all revisions associated with a change."""
323 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
326 def GetChangeReview(host, change, revision=None):
327 """Get the current review information for a change."""
329 jmsg = GetChangeRevisions(host, change)
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))
339 def 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)
347 def 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)
355 def 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)
363 def 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))
369 def 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))
375 def AddReviewers(host, change, add=None):
376 """Add reviewers to a change."""
379 if isinstance(add, basestring):
381 path = 'changes/%s/reviewers' % change
383 body = {'reviewer': r}
384 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
385 jmsg = ReadHttpJsonResponse(conn, ignore_404=False)
389 def RemoveReviewers(host, change, remove=None):
390 """Remove reveiewers from a change."""
393 if isinstance(remove, basestring):
396 path = 'changes/%s/reviewers/%s' % (change, r)
397 conn = CreateHttpConn(host, path, reqtype='DELETE')
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:
406 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
407 ' from change %s' % (r, change))
410 def 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:
414 path = 'changes/%s/revisions/current/review' % change
417 body['message'] = msg
419 body['labels'] = labels
421 body['notify'] = notify
422 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
423 response = ReadHttpJsonResponse(conn)
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.' % (
432 def ResetReviewLabels(host, change, label, value='0', message=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)
442 200, 'Could not get review information for change "%s"' % change)
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)
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:
456 'labels': {label: value},
457 'on_behalf_of': review['_account_id'],
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)
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)