blob: c8d838c7eaced6565fbb08ec12991cac2e37ba6d [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
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)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000068
szager@chromium.orgb4696232013-10-16 19:45:35 +000069 if auth:
70 headers.setdefault('Authorization', 'Basic %s' % (
71 base64.b64encode('%s:%s' % (auth[0], auth[2]))))
72 else:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000073 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
szager@chromium.orgb4696232013-10-16 19:45:35 +000080 if body:
81 body = json.JSONEncoder().encode(body)
82 headers.setdefault('Content-Type', 'application/json')
83 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000084 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +000085 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 = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000094 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +000095 '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:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000143 reason = '%s: %s' % (response.reason, response.read())
144 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000145 return StringIO(response.read())
146
147
148def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
149 """Parses an https response as json."""
150 fh = ReadHttpResponse(
151 conn, expect_status=expect_status, ignore_404=ignore_404)
152 # The first line of the response should always be: )]}'
153 s = fh.readline()
154 if s and s.rstrip() != ")]}'":
155 raise GerritError(200, 'Unexpected json output: %s' % s)
156 s = fh.read()
157 if not s:
158 return None
159 return json.loads(s)
160
161
162def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
163 sortkey=None):
164 """
165 Queries a gerrit-on-borg server for changes matching query terms.
166
167 Args:
168 param_dict: A dictionary of search parameters, as documented here:
169 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
170 first_param: A change identifier
171 limit: Maximum number of results to return.
172 o_params: A list of additional output specifiers, as documented here:
173 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
174 Returns:
175 A list of json-decoded query results.
176 """
177 # Note that no attempt is made to escape special characters; YMMV.
178 if not param_dict and not first_param:
179 raise RuntimeError('QueryChanges requires search parameters')
180 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
181 if sortkey:
182 path = '%s&N=%s' % (path, sortkey)
183 if limit:
184 path = '%s&n=%d' % (path, limit)
185 if o_params:
186 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
187 # Don't ignore 404; a query should always return a list, even if it's empty.
188 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
189
190
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000191def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
192 o_params=None, sortkey=None):
193 """
194 Queries a gerrit-on-borg server for all the changes matching the query terms.
195
196 A single query to gerrit-on-borg is limited on the number of results by the
197 limit parameter on the request (see QueryChanges) and the server maximum
198 limit. This function uses the "_more_changes" and "_sortkey" attributes on
199 the returned changes to iterate all of them making multiple queries to the
200 server, regardless the query limit.
201
202 Args:
203 param_dict, first_param: Refer to QueryChanges().
204 limit: Maximum number of requested changes per query.
205 o_params: Refer to QueryChanges().
206 sortkey: The value of the "_sortkey" attribute where starts from. None to
207 start from the first change.
208
209 Returns:
210 A generator object to the list of returned changes, possibly unbound.
211 """
212 more_changes = True
213 while more_changes:
214 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
215 for cl in page:
216 yield cl
217
218 more_changes = [cl for cl in page if '_more_changes' in cl]
219 if len(more_changes) > 1:
220 raise GerritError(
221 200,
222 'Received %d changes with a _more_changes attribute set but should '
223 'receive at most one.' % len(more_changes))
224 if more_changes:
225 sortkey = more_changes[0]['_sortkey']
226
227
szager@chromium.orgb4696232013-10-16 19:45:35 +0000228def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
229 sortkey=None):
230 """Initiate a query composed of multiple sets of query parameters."""
231 if not change_list:
232 raise RuntimeError(
233 "MultiQueryChanges requires a list of change numbers/id's")
234 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
235 if param_dict:
236 q.append(_QueryString(param_dict))
237 if limit:
238 q.append('n=%d' % limit)
239 if sortkey:
240 q.append('N=%s' % sortkey)
241 if o_params:
242 q.extend(['o=%s' % p for p in o_params])
243 path = 'changes/?%s' % '&'.join(q)
244 try:
245 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
246 except GerritError as e:
247 msg = '%s:\n%s' % (e.message, path)
248 raise GerritError(e.http_status, msg)
249 return result
250
251
252def GetGerritFetchUrl(host):
253 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
254 return '%s://%s/' % (GERRIT_PROTOCOL, host)
255
256
257def GetChangePageUrl(host, change_number):
258 """Given a gerrit host name and change number, return change page url."""
259 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
260
261
262def GetChangeUrl(host, change):
263 """Given a gerrit host name and change id, return an url for the change."""
264 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
265
266
267def GetChange(host, change):
268 """Query a gerrit server for information about a single change."""
269 path = 'changes/%s' % change
270 return ReadHttpJsonResponse(CreateHttpConn(host, path))
271
272
273def GetChangeDetail(host, change, o_params=None):
274 """Query a gerrit server for extended information about a single change."""
275 path = 'changes/%s/detail' % change
276 if o_params:
277 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
278 return ReadHttpJsonResponse(CreateHttpConn(host, path))
279
280
281def GetChangeCurrentRevision(host, change):
282 """Get information about the latest revision for a given change."""
283 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
284
285
286def GetChangeRevisions(host, change):
287 """Get information about all revisions associated with a change."""
288 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
289
290
291def GetChangeReview(host, change, revision=None):
292 """Get the current review information for a change."""
293 if not revision:
294 jmsg = GetChangeRevisions(host, change)
295 if not jmsg:
296 return None
297 elif len(jmsg) > 1:
298 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
299 revision = jmsg[0]['current_revision']
300 path = 'changes/%s/revisions/%s/review'
301 return ReadHttpJsonResponse(CreateHttpConn(host, path))
302
303
304def AbandonChange(host, change, msg=''):
305 """Abandon a gerrit change."""
306 path = 'changes/%s/abandon' % change
307 body = {'message': msg} if msg else None
308 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
309 return ReadHttpJsonResponse(conn, ignore_404=False)
310
311
312def RestoreChange(host, change, msg=''):
313 """Restore a previously abandoned change."""
314 path = 'changes/%s/restore' % change
315 body = {'message': msg} if msg else None
316 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
317 return ReadHttpJsonResponse(conn, ignore_404=False)
318
319
320def SubmitChange(host, change, wait_for_merge=True):
321 """Submits a gerrit change via Gerrit."""
322 path = 'changes/%s/submit' % change
323 body = {'wait_for_merge': wait_for_merge}
324 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
325 return ReadHttpJsonResponse(conn, ignore_404=False)
326
327
328def GetReviewers(host, change):
329 """Get information about all reviewers attached to a change."""
330 path = 'changes/%s/reviewers' % change
331 return ReadHttpJsonResponse(CreateHttpConn(host, path))
332
333
334def GetReview(host, change, revision):
335 """Get review information about a specific revision of a change."""
336 path = 'changes/%s/revisions/%s/review' % (change, revision)
337 return ReadHttpJsonResponse(CreateHttpConn(host, path))
338
339
340def AddReviewers(host, change, add=None):
341 """Add reviewers to a change."""
342 if not add:
343 return
344 if isinstance(add, basestring):
345 add = (add,)
346 path = 'changes/%s/reviewers' % change
347 for r in add:
348 body = {'reviewer': r}
349 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
350 jmsg = ReadHttpJsonResponse(conn, ignore_404=False)
351 return jmsg
352
353
354def RemoveReviewers(host, change, remove=None):
355 """Remove reveiewers from a change."""
356 if not remove:
357 return
358 if isinstance(remove, basestring):
359 remove = (remove,)
360 for r in remove:
361 path = 'changes/%s/reviewers/%s' % (change, r)
362 conn = CreateHttpConn(host, path, reqtype='DELETE')
363 try:
364 ReadHttpResponse(conn, ignore_404=False)
365 except GerritError as e:
366 # On success, gerrit returns status 204; anything else is an error.
367 if e.http_status != 204:
368 raise
369 else:
370 raise GerritError(
371 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
372 ' from change %s' % (r, change))
373
374
375def SetReview(host, change, msg=None, labels=None, notify=None):
376 """Set labels and/or add a message to a code review."""
377 if not msg and not labels:
378 return
379 path = 'changes/%s/revisions/current/review' % change
380 body = {}
381 if msg:
382 body['message'] = msg
383 if labels:
384 body['labels'] = labels
385 if notify:
386 body['notify'] = notify
387 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
388 response = ReadHttpJsonResponse(conn)
389 if labels:
390 for key, val in labels.iteritems():
391 if ('labels' not in response or key not in response['labels'] or
392 int(response['labels'][key] != int(val))):
393 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
394 key, change))
395
396
397def ResetReviewLabels(host, change, label, value='0', message=None,
398 notify=None):
399 """Reset the value of a given label for all reviewers on a change."""
400 # This is tricky, because we want to work on the "current revision", but
401 # there's always the risk that "current revision" will change in between
402 # API calls. So, we check "current revision" at the beginning and end; if
403 # it has changed, raise an exception.
404 jmsg = GetChangeCurrentRevision(host, change)
405 if not jmsg:
406 raise GerritError(
407 200, 'Could not get review information for change "%s"' % change)
408 value = str(value)
409 revision = jmsg[0]['current_revision']
410 path = 'changes/%s/revisions/%s/review' % (change, revision)
411 message = message or (
412 '%s label set to %s programmatically.' % (label, value))
413 jmsg = GetReview(host, change, revision)
414 if not jmsg:
415 raise GerritError(200, 'Could not get review information for revison %s '
416 'of change %s' % (revision, change))
417 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
418 if str(review.get('value', value)) != value:
419 body = {
420 'message': message,
421 'labels': {label: value},
422 'on_behalf_of': review['_account_id'],
423 }
424 if notify:
425 body['notify'] = notify
426 conn = CreateHttpConn(
427 host, path, reqtype='POST', body=body)
428 response = ReadHttpJsonResponse(conn)
429 if str(response['labels'][label]) != value:
430 username = review.get('email', jmsg.get('name', ''))
431 raise GerritError(200, 'Unable to set %s label for user "%s"'
432 ' on change %s.' % (label, username, change))
433 jmsg = GetChangeCurrentRevision(host, change)
434 if not jmsg:
435 raise GerritError(
436 200, 'Could not get review information for change "%s"' % change)
437 elif jmsg[0]['current_revision'] != revision:
438 raise GerritError(200, 'While resetting labels on change "%s", '
439 'a new patchset was uploaded.' % change)