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.
5 """Utilities for requesting information for a gerrit server via https.
7 https://gerrit-review.googlesource.com/Documentation/rest-api.html
18 from cStringIO import StringIO
20 from chromite.lib import retry_util
25 except (IOError, netrc.NetrcParseError):
26 NETRC = netrc.netrc(os.devnull)
27 LOGGER = logging.getLogger()
31 # Controls the transport protocol used to communicate with gerrit.
32 # This is parameterized primarily to enable cros_test_lib.GerritTestCase.
33 GERRIT_PROTOCOL = 'https'
36 class GOBError(Exception):
37 """Exception class for errors commuicating with the gerrit-on-borg service."""
38 def __init__(self, http_status, *args, **kwargs):
39 super(GOBError, self).__init__(*args, **kwargs)
40 self.http_status = http_status
41 self.message = '(%d) %s' % (self.http_status, self.message)
44 class InternalGOBError(GOBError):
45 """Exception class for GOB errors with status >= 500"""
48 def _QueryString(param_dict, first_param=None):
49 """Encodes query parameters in the key:val[+key:val...] format specified here:
51 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
53 q = [urllib.quote(first_param)] if first_param else []
54 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
58 def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
59 """Opens an https connection to a gerrit service, and sends a request."""
60 headers = headers or {}
61 bare_host = host.partition(':')[0]
62 auth = NETRC.authenticators(bare_host)
64 headers.setdefault('Authorization', 'Basic %s' % (
65 base64.b64encode('%s:%s' % (auth[0], auth[2]))))
67 LOGGER.debug('No authorization found')
69 body = json.JSONEncoder().encode(body)
70 headers.setdefault('Content-Type', 'application/json')
71 if LOGGER.isEnabledFor(logging.DEBUG):
72 LOGGER.debug('%s %s://%s/a/%s' % (reqtype, GERRIT_PROTOCOL, host, path))
73 for key, val in headers.iteritems():
74 if key == 'Authorization':
76 LOGGER.debug('%s: %s' % (key, val))
79 conn = httplib.HTTPSConnection(host)
82 'url': '/a/%s' % path,
87 conn.request(**conn.req_params)
91 def FetchUrl(host, path, reqtype='GET', headers=None, body=None,
93 """Fetches the http response from the specified URL into a string buffer.
96 host: The hostname of the Gerrit service.
97 path: The path on the Gerrit service. This will be prefixed with '/a'
99 reqtype: The request type. Can be GET or POST.
100 headers: A mapping of extra HTTP headers to pass in with the request.
101 body: A string of data to send after the headers are finished.
102 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
103 doesn't match the database contents. In most such cases, we
104 want the API to return None rather than raise an Exception.
107 A string buffer containing the connection's reply.
109 def _FetchUrlHelper():
110 err_prefix = 'A transient error occured while querying %s:\n' % (host,)
112 conn = CreateHttpConn(host, path, reqtype=reqtype, headers=headers,
114 response = conn.getresponse()
115 except socket.error as ex:
116 LOGGER.warn('%s%s', err_prefix, str(ex))
119 # Normal/good responses.
120 if response.status == 404 and ignore_404:
122 elif response.status == 200:
123 return StringIO(response.read())
126 LOGGER.debug('response msg:\n%s', response.msg)
127 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
128 msg = ('%s %s %s\n%s %d %s' %
129 (reqtype, conn.req_params['url'], http_version,
130 http_version, response.status, response.reason))
133 if response.status >= 500:
134 # A status >=500 is assumed to be a possible transient error; retry.
135 LOGGER.warn('%s%s', err_prefix, msg)
136 raise InternalGOBError(response.status, response.reason)
138 # Ones we cannot retry.
139 home = os.environ.get('HOME', '~')
140 url = 'https://%s/new-password' % host
141 if response.status in (302, 303, 307):
142 err_prefix = ('Redirect found; missing/bad %s/.netrc credentials?\n'
143 ' See %s' % (home, url))
144 elif response.status in (400,):
145 err_prefix = 'Permission error; talk to the admins of the GoB instance'
146 elif response.status in (401,):
147 err_prefix = ('Authorization error; missing/bad %s/.netrc credentials?\n'
148 ' See %s' % (home, url))
149 elif response.status in (422,):
150 err_prefix = ('Bad request body? Response body: "%s"' % response.read())
152 if response.status >= 400:
153 # The 'X-ErrorId' header is set only on >= 400 response code.
154 LOGGER.warn('%s\n%s\nX-ErrorId: %s', err_prefix, msg,
155 response.getheader('X-ErrorId'))
157 LOGGER.warn('%s\n%s', err_prefix, msg)
159 LOGGER.warn('conn.sock.getpeername(): %s', conn.sock.getpeername())
160 raise GOBError(response.status, response.reason)
162 return retry_util.RetryException((socket.error, InternalGOBError), TRY_LIMIT,
163 _FetchUrlHelper, sleep=SLEEP)
166 def FetchUrlJson(*args, **kwargs):
167 """Fetch the specified URL and parse it as JSON.
169 See FetchUrl for arguments.
171 fh = FetchUrl(*args, **kwargs)
172 # The first line of the response should always be: )]}'
174 if s and s.rstrip() != ")]}'":
175 raise GOBError(200, 'Unexpected json output: %s' % s)
182 def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
184 """Queries a gerrit-on-borg server for changes matching query terms.
187 host: The Gerrit server hostname.
188 param_dict: A dictionary of search parameters, as documented here:
189 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
190 first_param: A change identifier
191 limit: Maximum number of results to return.
192 o_params: A list of additional output specifiers, as documented here:
193 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
194 sortkey: Positions the low level scan routine to start from this |sortkey|
195 and continue through changes from this point. This is most often used
196 for paginating result sets.
199 A list of json-decoded query results.
201 # Note that no attempt is made to escape special characters; YMMV.
202 if not param_dict and not first_param:
203 raise RuntimeError('QueryChanges requires search parameters')
204 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
206 path = '%s&N=%s' % (path, sortkey)
208 path = '%s&n=%d' % (path, limit)
210 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
211 # Don't ignore 404; a query should always return a list, even if it's empty.
212 return FetchUrlJson(host, path, ignore_404=False)
215 def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
217 """Initiate a query composed of multiple sets of query parameters."""
220 "MultiQueryChanges requires a list of change numbers/id's")
221 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
223 q.append(_QueryString(param_dict))
225 q.append('n=%d' % limit)
227 q.append('N=%s' % sortkey)
229 q.extend(['o=%s' % p for p in o_params])
230 path = 'changes/?%s' % '&'.join(q)
232 result = FetchUrlJson(host, path, ignore_404=False)
233 except GOBError as e:
234 msg = '%s:\n%s' % (e.message, path)
235 raise GOBError(e.http_status, msg)
239 def GetGerritFetchUrl(host):
240 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
241 return '%s://%s/' % (GERRIT_PROTOCOL, host)
244 def GetChangePageUrl(host, change_number):
245 """Given a gerrit host name and change number, return change page url."""
246 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
249 def GetChangeUrl(host, change):
250 """Given a gerrit host name and change id, return an url for the change."""
251 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
254 def GetChange(host, change):
255 """Query a gerrit server for information about a single change."""
256 path = 'changes/%s' % change
257 return FetchUrlJson(host, path)
260 def GetChangeReview(host, change, revision='current'):
261 """Get the current review information for a change."""
262 path = 'changes/%s/revisions/%s/review' % (change, revision)
263 return FetchUrlJson(host, path)
266 def GetChangeCommit(host, change, revision='current'):
267 """Get the current review information for a change."""
268 path = 'changes/%s/revisions/%s/commit' % (change, revision)
269 return FetchUrlJson(host, path)
272 def GetChangeCurrentRevision(host, change):
273 """Get information about the latest revision for a given change."""
274 jmsg = GetChangeReview(host, change)
276 return jmsg.get('current_revision')
279 def GetChangeDetail(host, change, o_params=None):
280 """Query a gerrit server for extended information about a single change."""
281 path = 'changes/%s/detail' % change
283 path = '%s?%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
284 return FetchUrlJson(host, path)
287 def GetChangeReviewers(host, change):
288 """Get information about all reviewers attached to a change."""
289 path = 'changes/%s/reviewers' % change
290 return FetchUrlJson(host, path)
293 def AbandonChange(host, change, msg=''):
294 """Abandon a gerrit change."""
295 path = 'changes/%s/abandon' % change
296 body = {'message': msg} if msg else None
297 return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
300 def RestoreChange(host, change, msg=''):
301 """Restore a previously abandoned change."""
302 path = 'changes/%s/restore' % change
303 body = {'message': msg} if msg else None
304 return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
307 def DeleteDraft(host, change, msg=''):
308 """Delete a gerrit draft patch set."""
309 path = 'changes/%s' % change
310 body = {'message': msg} if msg else None
312 FetchUrl(host, path, reqtype='DELETE', body=body, ignore_404=False)
313 except GOBError as e:
314 # On success, gerrit returns status 204; anything else is an error.
315 if e.http_status != 204:
319 'Unexpectedly received a 200 http status while deleting draft %r'
323 def SubmitChange(host, change, wait_for_merge=True):
324 """Submits a gerrit change via Gerrit."""
325 path = 'changes/%s/submit' % change
326 body = {'wait_for_merge': wait_for_merge}
327 return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
330 def GetReviewers(host, change):
331 """Get information about all reviewers attached to a change."""
332 path = 'changes/%s/reviewers' % change
333 return FetchUrlJson(host, path)
336 def AddReviewers(host, change, add=None):
337 """Add reviewers to a change."""
340 if isinstance(add, basestring):
342 path = 'changes/%s/reviewers' % change
344 body = {'reviewer': r}
345 jmsg = FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
349 def RemoveReviewers(host, change, remove=None):
350 """Remove reveiewers from a change."""
353 if isinstance(remove, basestring):
356 path = 'changes/%s/reviewers/%s' % (change, r)
358 FetchUrl(host, path, reqtype='DELETE', ignore_404=False)
359 except GOBError as e:
360 # On success, gerrit returns status 204; anything else is an error.
361 if e.http_status != 204:
365 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
366 ' from change %s' % (r, change))
369 def SetReview(host, change, revision='current', msg=None, labels=None,
371 """Set labels and/or add a message to a code review."""
372 if not msg and not labels:
374 path = 'changes/%s/revisions/%s/review' % (change, revision)
377 body['message'] = msg
379 body['labels'] = labels
381 body['notify'] = notify
382 response = FetchUrlJson(host, path, reqtype='POST', body=body)
384 for key, val in labels.iteritems():
385 if ('labels' not in response or key not in response['labels'] or
386 int(response['labels'][key] != int(val))):
387 raise GOBError(200, 'Unable to set "%s" label on change %s.' % (
391 def ResetReviewLabels(host, change, label, value='0', revision='current',
392 message=None, notify=None):
393 """Reset the value of a given label for all reviewers on a change."""
394 # This is tricky when working on the "current" revision, because there's
395 # always the risk that the "current" revision will change in between API
396 # calls. So, the code dereferences the "current" revision down to a literal
397 # sha1 at the beginning and uses it for all subsequent calls. As a sanity
398 # check, the "current" revision is dereferenced again at the end, and if it
399 # differs from the previous "current" revision, an exception is raised.
400 current = (revision == 'current')
401 jmsg = GetChangeDetail(
402 host, change, o_params=['CURRENT_REVISION', 'CURRENT_COMMIT'])
404 revision = jmsg['current_revision']
406 path = 'changes/%s/revisions/%s/review' % (change, revision)
407 message = message or (
408 '%s label set to %s programmatically by chromite.' % (label, value))
409 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
410 if str(review.get('value', value)) != value:
413 'labels': {label: value},
414 'on_behalf_of': review['_account_id'],
417 body['notify'] = notify
418 response = FetchUrlJson(host, path, reqtype='POST', body=body)
419 if str(response['labels'][label]) != value:
420 username = review.get('email', jmsg.get('name', ''))
421 raise GOBError(200, 'Unable to set %s label for user "%s"'
422 ' on change %s.' % (label, username, change))
424 new_revision = GetChangeCurrentRevision(host, change)
427 200, 'Could not get review information for change "%s"' % change)
428 elif new_revision != revision:
429 raise GOBError(200, 'While resetting labels on change "%s", '
430 'a new patchset was uploaded.' % change)