Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(537)

Side by Side Diff: gerrit_util.py

Issue 26399002: Add git/gerrit-on-borg utilities. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools
Patch Set: Cleanup, minor refactor, doc string. Created 7 years, 2 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « no previous file | testing_support/gerrit_test_case.py » ('j') | testing_support/gerrit_test_case.py » ('J')
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
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.
4
5 """
6 Utilities for requesting information for a gerrit server via https.
7
8 https://gerrit-review.googlesource.com/Documentation/rest-api.html
9 """
10
11 import base64
12 import httplib
13 import json
14 import logging
15 import netrc
16 import os
17 import time
18 import urllib
19 from cStringIO import StringIO
20
21 try:
22 NETRC = netrc.netrc()
23 except (IOError, netrc.NetrcParseError):
24 NETRC = netrc.netrc(os.devnull)
25 LOGGER = logging.getLogger()
26 TRY_LIMIT = 5
27
28 # Controls the transport protocol used to communicate with gerrit.
29 # This is parameterized primarily to enable GerritTestCase.
30 GERRIT_PROTOCOL = 'https'
31
32
33 class 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
41 def _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#lis t-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
51 def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
52 """Opens an https connection to a gerrit service, and sends a request."""
53 headers = headers or {}
54 bare_host = host.partition(':')[0]
55 auth = NETRC.authenticators(bare_host)
56 if auth:
57 headers.setdefault('Authorization', 'Basic %s' % (
58 base64.b64encode('%s:%s' % (auth[0], auth[2]))))
59 else:
60 LOGGER.debug('No authorization found')
61 if body:
62 body = json.JSONEncoder().encode(body)
63 headers.setdefault('Content-Type', 'application/json')
64 if LOGGER.isEnabledFor(logging.DEBUG):
65 LOGGER.debug('%s %s://%s/a/%s' % (reqtype, GERRIT_PROTOCOL, host, path))
66 for key, val in headers.iteritems():
67 if key == 'Authorization':
68 val = 'HIDDEN'
69 LOGGER.debug('%s: %s' % (key, val))
70 if body:
71 LOGGER.debug(body)
72 conn = httplib.HTTPSConnection(host)
Vadim Sh. 2013/10/10 21:55:47 Can we choose between HTTPConnection and HTTPSConn
szager 2013/10/10 23:58:07 Great idea; done.
73 conn.req_host = host
74 conn.req_params = {
75 'url': '/a/%s' % path,
76 'method': reqtype,
77 'headers': headers,
78 'body': body,
79 }
80 conn.request(**conn.req_params)
81 return conn
82
83
84 def ReadHttpResponse(conn, ignore_404=True):
85 """Reads an http response from a connection into a string buffer.
86
87 Args:
88 conn: An HTTPSConnection created by CreateHttpConn, above.
89 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
90 doesn't match the database contents. In most such cases, we
91 want the API to return None rather than raise an Exception.
92 Returns: A string buffer containing the connection's reply.
93 """
94
95 sleep_time = 0.5
96 for idx in range(TRY_LIMIT):
97 response = conn.getresponse()
98 # If response.status < 500 then the result is final; break retry loop.
99 if response.status < 500:
100 break
101 # A status >=500 is assumed to be a possible transient error; retry.
102 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
103 msg = (
104 'A transient error occured while querying %s:\n'
105 '%s %s %s\n'
106 '%s %d %s' % (
107 conn.host, conn.req_params['method'], conn.req_params['url'],
108 http_version, http_version, response.status, response.reason))
109 if TRY_LIMIT - idx > 1:
110 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
111 time.sleep(sleep_time)
112 sleep_time = sleep_time * 2
113 req_host = conn.req_host
114 req_params = conn.req_params
115 conn = httplib.HTTPSConnection(req_host)
116 conn.req_host = req_host
117 conn.req_params = req_params
118 conn.request(**req_params)
119 LOGGER.warn(msg)
120 if ignore_404 and response.status == 404:
121 return StringIO()
122 if response.status != 200:
123 raise GerritError(response.status, response.reason)
124 return StringIO(response.read())
125
126
127 def ReadHttpJsonResponse(conn, ignore_404=True):
128 """Parses an https response as json."""
129 fh = ReadHttpResponse(conn, ignore_404=ignore_404)
130 # The first line of the response should always be: )]}'
131 s = fh.readline()
132 if s and s.rstrip() != ")]}'":
133 raise GerritError(200, 'Unexpected json output: %s' % s)
134 s = fh.read()
135 if not s:
136 return None
137 return json.loads(s)
138
139
140 def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
141 sortkey=None):
142 """
143 Queries a gerrit-on-borg server for changes matching query terms.
144
145 Args:
146 param_dict: A dictionary of search parameters, as documented here:
147 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-se arch.html
148 first_param: A change identifier
149 limit: Maximum number of results to return.
150 o_params: A list of additional output specifiers, as documented here:
151 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.ht ml#list-changes
152 Returns:
153 A list of json-decoded query results.
154 """
155 # Note that no attempt is made to escape special characters; YMMV.
156 if not param_dict and not first_param:
157 raise RuntimeError('QueryChanges requires search parameters')
158 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
159 if sortkey:
160 path = '%s&N=%s' % (path, sortkey)
161 if limit:
162 path = '%s&n=%d' % (path, limit)
163 if o_params:
164 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
165 # Don't ignore 404; a query should always return a list, even if it's empty.
166 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
167
168
169 def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
170 sortkey=None):
171 """Initiate a query composed of multiple sets of query parameters."""
172 if not change_list:
173 raise RuntimeError(
174 "MultiQueryChanges requires a list of change numbers/id's")
175 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
176 if param_dict:
177 q.append(_QueryString(param_dict))
178 if limit:
179 q.append('n=%d' % limit)
180 if sortkey:
181 q.append('N=%s' % sortkey)
182 if o_params:
183 q.extend(['o=%s' % p for p in o_params])
184 path = 'changes/?%s' % '&'.join(q)
185 try:
186 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
187 except GerritError as e:
188 msg = '%s:\n%s' % (e.message, path)
189 raise GerritError(e.http_status, msg)
190 return result
191
192
193 def GetGerritFetchUrl(host):
194 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
195 return '%s://%s/' % (GERRIT_PROTOCOL, host)
196
197
198 def GetChangePageUrl(host, change_number):
199 """Given a gerrit host name and change number, return change page url."""
200 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
201
202
203 def GetChangeUrl(host, change):
204 """Given a gerrit host name and change id, return an url for the change."""
205 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
206
207
208 def GetChange(host, change):
209 """Query a gerrit server for information about a single change."""
210 path = 'changes/%s' % change
211 return ReadHttpJsonResponse(CreateHttpConn(host, path))
212
213
214 def GetChangeDetail(host, change, o_params=None):
215 """Query a gerrit server for extended information about a single change."""
216 path = 'changes/%s/detail' % change
217 if o_params:
218 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
219 return ReadHttpJsonResponse(CreateHttpConn(host, path))
220
221
222 def GetChangeCurrentRevision(host, change):
223 """Get information about the latest revision for a given change."""
224 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
225
226
227 def GetChangeRevisions(host, change):
228 """Get information about all revisions associated with a change."""
229 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
230
231
232 def GetChangeReview(host, change, revision=None):
233 """Get the current review information for a change."""
234 if not revision:
235 jmsg = GetChangeRevisions(host, change)
236 if not jmsg:
237 return None
238 elif len(jmsg) > 1:
239 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
240 revision = jmsg[0]['current_revision']
241 path = 'changes/%s/revisions/%s/review'
242 return ReadHttpJsonResponse(CreateHttpConn(host, path))
243
244
245 def AbandonChange(host, change, msg=''):
246 """Abandon a gerrit change."""
247 path = 'changes/%s/abandon' % change
248 body = {'message': msg} if msg else None
249 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
250 return ReadHttpJsonResponse(conn, ignore_404=False)
251
252
253 def RestoreChange(host, change, msg=''):
254 """Restore a previously abandoned change."""
255 path = 'changes/%s/restore' % change
256 body = {'message': msg} if msg else None
257 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
258 return ReadHttpJsonResponse(conn, ignore_404=False)
259
260
261 def SubmitChange(host, change, wait_for_merge=True):
262 """Submits a gerrit change via Gerrit."""
263 path = 'changes/%s/submit' % change
264 body = {'wait_for_merge': wait_for_merge}
265 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
266 return ReadHttpJsonResponse(conn, ignore_404=False)
267
268
269 def GetReviewers(host, change):
270 """Get information about all reviewers attached to a change."""
271 path = 'changes/%s/reviewers' % change
272 return ReadHttpJsonResponse(CreateHttpConn(host, path))
273
274
275 def GetReview(host, change, revision):
276 """Get review information about a specific revision of a change."""
277 path = 'changes/%s/revisions/%s/review' % (change, revision)
278 return ReadHttpJsonResponse(CreateHttpConn(host, path))
279
280
281 def AddReviewers(host, change, add=None):
282 """Add reviewers to a change."""
283 if not add:
284 return
285 if isinstance(add, basestring):
286 add = (add,)
287 path = 'changes/%s/reviewers' % change
288 for r in add:
289 body = {'reviewer': r}
290 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
291 jmsg = ReadHttpJsonResponse(conn, ignore_404=False)
292 return jmsg
293
294
295 def RemoveReviewers(host, change, remove=None):
296 """Remove reveiewers from a change."""
297 if not remove:
298 return
299 if isinstance(remove, basestring):
300 remove = (remove,)
301 for r in remove:
302 path = 'changes/%s/reviewers/%s' % (change, r)
303 conn = CreateHttpConn(host, path, reqtype='DELETE')
304 try:
305 ReadHttpResponse(conn, ignore_404=False)
306 except GerritError as e:
307 # On success, gerrit returns status 204; anything else is an error.
308 if e.http_status != 204:
309 raise
310 else:
311 raise GerritError(
312 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
313 ' from change %s' % (r, change))
314
315
316 def SetReview(host, change, msg=None, labels=None, notify=None):
317 """Set labels and/or add a message to a code review."""
318 if not msg and not labels:
319 return
320 path = 'changes/%s/revisions/current/review' % change
321 body = {}
322 if msg:
323 body['message'] = msg
324 if labels:
325 body['labels'] = labels
326 if notify:
327 body['notify'] = notify
328 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
329 response = ReadHttpJsonResponse(conn)
330 if labels:
331 for key, val in labels.iteritems():
332 if ('labels' not in response or key not in response['labels'] or
333 int(response['labels'][key] != int(val))):
334 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
335 key, change))
336
337
338 def ResetReviewLabels(host, change, label, value='0', message=None,
339 notify=None):
340 """Reset the value of a given label for all reviewers on a change."""
341 # This is tricky, because we want to work on the "current revision", but
342 # there's always the risk that "current revision" will change in between
343 # API calls. So, we check "current revision" at the beginning and end; if
344 # it has changed, raise an exception.
345 jmsg = GetChangeCurrentRevision(host, change)
346 if not jmsg:
347 raise GerritError(
348 200, 'Could not get review information for change "%s"' % change)
349 value = str(value)
350 revision = jmsg[0]['current_revision']
351 path = 'changes/%s/revisions/%s/review' % (change, revision)
352 message = message or (
353 '%s label set to %s programmatically by chromite.' % (label, value))
Vadim Sh. 2013/10/10 21:55:47 'by chromite' part is no longer correct.
szager 2013/10/10 23:58:07 Done.
354 jmsg = GetReview(host, change, revision)
355 if not jmsg:
356 raise GerritError(200, 'Could not get review information for revison %s '
357 'of change %s' % (revision, change))
358 for review in jmsg.get('labels', {}).get('Commit-Queue', {}).get('all', []):
Vadim Sh. 2013/10/10 21:55:47 Commit-Queue is ChromeOS specific label. Shouldn't
szager 2013/10/10 23:58:07 Yes, thanks, I fixed this in chromite already, but
359 if str(review.get('value', value)) != value:
360 body = {
361 'message': message,
362 'labels': {label: value},
363 'on_behalf_of': review['_account_id'],
364 }
365 if notify:
366 body['notify'] = notify
367 conn = CreateHttpConn(
368 host, path, reqtype='POST', body=body)
369 response = ReadHttpJsonResponse(conn)
370 if str(response['labels'][label]) != value:
371 username = review.get('email', jmsg.get('name', ''))
372 raise GerritError(200, 'Unable to set %s label for user "%s"'
373 ' on change %s.' % (label, username, change))
374 jmsg = GetChangeCurrentRevision(host, change)
375 if not jmsg:
376 raise GerritError(
377 200, 'Could not get review information for change "%s"' % change)
378 elif jmsg[0]['current_revision'] != revision:
379 raise GerritError(200, 'While resetting labels on change "%s", '
380 'a new patchset was uploaded.' % change)
OLDNEW
« no previous file with comments | « no previous file | testing_support/gerrit_test_case.py » ('j') | testing_support/gerrit_test_case.py » ('J')

Powered by Google App Engine
This is Rietveld 408576698