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