blob: 38ac6aa2fd958bcc31b95d6fc79760593e0e58c2 [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
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000012import cookielib
szager@chromium.orgb4696232013-10-16 19:45:35 +000013import httplib
14import json
15import logging
16import netrc
17import os
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000018import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000019import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000020import stat
21import sys
szager@chromium.orgb4696232013-10-16 19:45:35 +000022import time
23import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000024import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000025from cStringIO import StringIO
26
szager@chromium.orgf202a252014-05-27 18:55:52 +000027
szager@chromium.orgb4696232013-10-16 19:45:35 +000028LOGGER = logging.getLogger()
29TRY_LIMIT = 5
30
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000031
szager@chromium.orgb4696232013-10-16 19:45:35 +000032# Controls the transport protocol used to communicate with gerrit.
33# This is parameterized primarily to enable GerritTestCase.
34GERRIT_PROTOCOL = 'https'
35
36
37class GerritError(Exception):
38 """Exception class for errors commuicating with the gerrit-on-borg service."""
39 def __init__(self, http_status, *args, **kwargs):
40 super(GerritError, self).__init__(*args, **kwargs)
41 self.http_status = http_status
42 self.message = '(%d) %s' % (self.http_status, self.message)
43
44
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000045class GerritAuthenticationError(GerritError):
46 """Exception class for authentication errors during Gerrit communication."""
47
48
szager@chromium.orgb4696232013-10-16 19:45:35 +000049def _QueryString(param_dict, first_param=None):
50 """Encodes query parameters in the key:val[+key:val...] format specified here:
51
52 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
53 """
54 q = [urllib.quote(first_param)] if first_param else []
55 q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
56 return '+'.join(q)
57
58
59def GetConnectionClass(protocol=None):
60 if protocol is None:
61 protocol = GERRIT_PROTOCOL
62 if protocol == 'https':
63 return httplib.HTTPSConnection
64 elif protocol == 'http':
65 return httplib.HTTPConnection
66 else:
67 raise RuntimeError(
68 "Don't know how to work with protocol '%s'" % protocol)
69
70
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000071class Authenticator(object):
72 """Base authenticator class for authenticator implementations to subclass."""
73
74 def get_auth_header(self, host):
75 raise NotImplementedError()
76
77 @staticmethod
78 def get():
79 """Returns: (Authenticator) The identified Authenticator to use.
80
81 Probes the local system and its environment and identifies the
82 Authenticator instance to use.
83 """
84 if GceAuthenticator.is_gce():
85 return GceAuthenticator()
86 return NetrcAuthenticator()
87
88
89class NetrcAuthenticator(Authenticator):
90 """Authenticator implementation that uses ".netrc" for token.
91 """
92
93 def __init__(self):
94 self.netrc = self._get_netrc()
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +000095 self.gitcookies = self._get_gitcookies()
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000096
97 @staticmethod
98 def _get_netrc():
99 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
mmoss@chromium.orgc3499712015-11-25 01:04:01 +0000100 path = os.path.expanduser(os.path.join('~', path))
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000101 try:
102 return netrc.netrc(path)
103 except IOError:
104 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
105 return netrc.netrc(os.devnull)
106 except netrc.NetrcParseError as e:
107 st = os.stat(e.path)
108 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
109 print >> sys.stderr, (
110 'WARNING: netrc file %s cannot be used because its file '
111 'permissions are insecure. netrc file permissions should be '
112 '600.' % path)
113 else:
114 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
115 'parsing error.' % path)
116 raise
117 return netrc.netrc(os.devnull)
118
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000119 @staticmethod
120 def _get_gitcookies():
121 gitcookies = {}
122 path = os.path.join(os.environ['HOME'], '.gitcookies')
123 try:
124 f = open(path, 'rb')
125 except IOError:
126 return gitcookies
127
128 with f:
129 for line in f:
130 try:
131 fields = line.strip().split('\t')
132 if line.strip().startswith('#') or len(fields) != 7:
133 continue
134 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6]
135 if xpath == '/' and key == 'o':
136 login, secret_token = value.split('=', 1)
137 gitcookies[domain] = (login, secret_token)
138 except (IndexError, ValueError, TypeError) as exc:
139 logging.warning(exc)
140
141 return gitcookies
142
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000143 def get_auth_header(self, host):
phajdan.jr@chromium.orgff7840a2015-11-04 16:35:22 +0000144 auth = None
145 for domain, creds in self.gitcookies.iteritems():
146 if cookielib.domain_match(host, domain):
147 auth = (creds[0], None, creds[1])
148 break
149
150 if not auth:
151 auth = self.netrc.authenticators(host)
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000152 if auth:
153 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
154 return None
155
156
157class GceAuthenticator(Authenticator):
158 """Authenticator implementation that uses GCE metadata service for token.
159 """
160
161 _INFO_URL = 'http://metadata.google.internal'
162 _ACQUIRE_URL = ('http://metadata/computeMetadata/v1/instance/'
163 'service-accounts/default/token')
164 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
165
166 _cache_is_gce = None
167 _token_cache = None
168 _token_expiration = None
169
170 @classmethod
171 def is_gce(cls):
172 if cls._cache_is_gce is None:
173 cls._cache_is_gce = cls._test_is_gce()
174 return cls._cache_is_gce
175
176 @classmethod
177 def _test_is_gce(cls):
178 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
179 try:
180 resp = cls._get(cls._INFO_URL)
181 except socket.error:
182 # Could not resolve URL.
183 return False
184 return resp.getheader('Metadata-Flavor', None) == 'Google'
185
186 @staticmethod
187 def _get(url, **kwargs):
188 next_delay_sec = 1
189 for i in xrange(TRY_LIMIT):
190 if i > 0:
191 # Retry server error status codes.
192 LOGGER.info('Encountered server error; retrying after %d second(s).',
193 next_delay_sec)
194 time.sleep(next_delay_sec)
195 next_delay_sec *= 2
196
197 p = urlparse.urlparse(url)
198 c = GetConnectionClass(protocol=p.scheme)(p.netloc)
199 c.request('GET', url, **kwargs)
200 resp = c.getresponse()
201 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
202 if resp.status < httplib.INTERNAL_SERVER_ERROR:
203 return resp
204
205
206 @classmethod
207 def _get_token_dict(cls):
208 if cls._token_cache:
209 # If it expires within 25 seconds, refresh.
210 if cls._token_expiration < time.time() - 25:
211 return cls._token_cache
212
213 resp = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
214 if resp.status != httplib.OK:
215 return None
216 cls._token_cache = json.load(resp)
217 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
218 return cls._token_cache
219
220 def get_auth_header(self, _host):
221 token_dict = self._get_token_dict()
222 if not token_dict:
223 return None
224 return '%(token_type)s %(access_token)s' % token_dict
225
226
227
szager@chromium.orgb4696232013-10-16 19:45:35 +0000228def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
229 """Opens an https connection to a gerrit service, and sends a request."""
230 headers = headers or {}
231 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000232
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000233 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000234 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000235 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000236 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000237 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000238
239 if 'Authorization' in headers and not path.startswith('a/'):
240 url = '/a/%s' % path
241 else:
242 url = '/%s' % path
243
szager@chromium.orgb4696232013-10-16 19:45:35 +0000244 if body:
245 body = json.JSONEncoder().encode(body)
246 headers.setdefault('Content-Type', 'application/json')
247 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000248 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000249 for key, val in headers.iteritems():
250 if key == 'Authorization':
251 val = 'HIDDEN'
252 LOGGER.debug('%s: %s' % (key, val))
253 if body:
254 LOGGER.debug(body)
255 conn = GetConnectionClass()(host)
256 conn.req_host = host
257 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000258 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000259 'method': reqtype,
260 'headers': headers,
261 'body': body,
262 }
263 conn.request(**conn.req_params)
264 return conn
265
266
267def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
268 """Reads an http response from a connection into a string buffer.
269
270 Args:
271 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
272 expect_status: Success is indicated by this status in the response.
273 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
274 doesn't match the database contents. In most such cases, we
275 want the API to return None rather than raise an Exception.
276 Returns: A string buffer containing the connection's reply.
277 """
278
279 sleep_time = 0.5
280 for idx in range(TRY_LIMIT):
281 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000282
283 # Check if this is an authentication issue.
284 www_authenticate = response.getheader('www-authenticate')
285 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
286 www_authenticate):
287 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
288 host = auth_match.group(1) if auth_match else conn.req_host
289 reason = ('Authentication failed. Please make sure your .netrc file '
290 'has credentials for %s' % host)
291 raise GerritAuthenticationError(response.status, reason)
292
szager@chromium.orgb4696232013-10-16 19:45:35 +0000293 # If response.status < 500 then the result is final; break retry loop.
294 if response.status < 500:
295 break
296 # A status >=500 is assumed to be a possible transient error; retry.
297 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
298 msg = (
299 'A transient error occured while querying %s:\n'
300 '%s %s %s\n'
301 '%s %d %s' % (
302 conn.host, conn.req_params['method'], conn.req_params['url'],
303 http_version, http_version, response.status, response.reason))
304 if TRY_LIMIT - idx > 1:
305 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
306 time.sleep(sleep_time)
307 sleep_time = sleep_time * 2
308 req_host = conn.req_host
309 req_params = conn.req_params
310 conn = GetConnectionClass()(req_host)
311 conn.req_host = req_host
312 conn.req_params = req_params
313 conn.request(**req_params)
314 LOGGER.warn(msg)
315 if ignore_404 and response.status == 404:
316 return StringIO()
317 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000318 reason = '%s: %s' % (response.reason, response.read())
319 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000320 return StringIO(response.read())
321
322
323def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
324 """Parses an https response as json."""
325 fh = ReadHttpResponse(
326 conn, expect_status=expect_status, ignore_404=ignore_404)
327 # The first line of the response should always be: )]}'
328 s = fh.readline()
329 if s and s.rstrip() != ")]}'":
330 raise GerritError(200, 'Unexpected json output: %s' % s)
331 s = fh.read()
332 if not s:
333 return None
334 return json.loads(s)
335
336
337def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
338 sortkey=None):
339 """
340 Queries a gerrit-on-borg server for changes matching query terms.
341
342 Args:
343 param_dict: A dictionary of search parameters, as documented here:
344 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
345 first_param: A change identifier
346 limit: Maximum number of results to return.
347 o_params: A list of additional output specifiers, as documented here:
348 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
349 Returns:
350 A list of json-decoded query results.
351 """
352 # Note that no attempt is made to escape special characters; YMMV.
353 if not param_dict and not first_param:
354 raise RuntimeError('QueryChanges requires search parameters')
355 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
356 if sortkey:
357 path = '%s&N=%s' % (path, sortkey)
358 if limit:
359 path = '%s&n=%d' % (path, limit)
360 if o_params:
361 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
362 # Don't ignore 404; a query should always return a list, even if it's empty.
363 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
364
365
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000366def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
367 o_params=None, sortkey=None):
368 """
369 Queries a gerrit-on-borg server for all the changes matching the query terms.
370
371 A single query to gerrit-on-borg is limited on the number of results by the
372 limit parameter on the request (see QueryChanges) and the server maximum
373 limit. This function uses the "_more_changes" and "_sortkey" attributes on
374 the returned changes to iterate all of them making multiple queries to the
375 server, regardless the query limit.
376
377 Args:
378 param_dict, first_param: Refer to QueryChanges().
379 limit: Maximum number of requested changes per query.
380 o_params: Refer to QueryChanges().
381 sortkey: The value of the "_sortkey" attribute where starts from. None to
382 start from the first change.
383
384 Returns:
385 A generator object to the list of returned changes, possibly unbound.
386 """
387 more_changes = True
388 while more_changes:
389 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
390 for cl in page:
391 yield cl
392
393 more_changes = [cl for cl in page if '_more_changes' in cl]
394 if len(more_changes) > 1:
395 raise GerritError(
396 200,
397 'Received %d changes with a _more_changes attribute set but should '
398 'receive at most one.' % len(more_changes))
399 if more_changes:
400 sortkey = more_changes[0]['_sortkey']
401
402
szager@chromium.orgb4696232013-10-16 19:45:35 +0000403def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
404 sortkey=None):
405 """Initiate a query composed of multiple sets of query parameters."""
406 if not change_list:
407 raise RuntimeError(
408 "MultiQueryChanges requires a list of change numbers/id's")
409 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
410 if param_dict:
411 q.append(_QueryString(param_dict))
412 if limit:
413 q.append('n=%d' % limit)
414 if sortkey:
415 q.append('N=%s' % sortkey)
416 if o_params:
417 q.extend(['o=%s' % p for p in o_params])
418 path = 'changes/?%s' % '&'.join(q)
419 try:
420 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
421 except GerritError as e:
422 msg = '%s:\n%s' % (e.message, path)
423 raise GerritError(e.http_status, msg)
424 return result
425
426
427def GetGerritFetchUrl(host):
428 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
429 return '%s://%s/' % (GERRIT_PROTOCOL, host)
430
431
432def GetChangePageUrl(host, change_number):
433 """Given a gerrit host name and change number, return change page url."""
434 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
435
436
437def GetChangeUrl(host, change):
438 """Given a gerrit host name and change id, return an url for the change."""
439 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
440
441
442def GetChange(host, change):
443 """Query a gerrit server for information about a single change."""
444 path = 'changes/%s' % change
445 return ReadHttpJsonResponse(CreateHttpConn(host, path))
446
447
448def GetChangeDetail(host, change, o_params=None):
449 """Query a gerrit server for extended information about a single change."""
450 path = 'changes/%s/detail' % change
451 if o_params:
452 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
453 return ReadHttpJsonResponse(CreateHttpConn(host, path))
454
455
456def GetChangeCurrentRevision(host, change):
457 """Get information about the latest revision for a given change."""
458 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
459
460
461def GetChangeRevisions(host, change):
462 """Get information about all revisions associated with a change."""
463 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
464
465
466def GetChangeReview(host, change, revision=None):
467 """Get the current review information for a change."""
468 if not revision:
469 jmsg = GetChangeRevisions(host, change)
470 if not jmsg:
471 return None
472 elif len(jmsg) > 1:
473 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
474 revision = jmsg[0]['current_revision']
475 path = 'changes/%s/revisions/%s/review'
476 return ReadHttpJsonResponse(CreateHttpConn(host, path))
477
478
479def AbandonChange(host, change, msg=''):
480 """Abandon a gerrit change."""
481 path = 'changes/%s/abandon' % change
482 body = {'message': msg} if msg else None
483 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
484 return ReadHttpJsonResponse(conn, ignore_404=False)
485
486
487def RestoreChange(host, change, msg=''):
488 """Restore a previously abandoned change."""
489 path = 'changes/%s/restore' % change
490 body = {'message': msg} if msg else None
491 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
492 return ReadHttpJsonResponse(conn, ignore_404=False)
493
494
495def SubmitChange(host, change, wait_for_merge=True):
496 """Submits a gerrit change via Gerrit."""
497 path = 'changes/%s/submit' % change
498 body = {'wait_for_merge': wait_for_merge}
499 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
500 return ReadHttpJsonResponse(conn, ignore_404=False)
501
502
503def GetReviewers(host, change):
504 """Get information about all reviewers attached to a change."""
505 path = 'changes/%s/reviewers' % change
506 return ReadHttpJsonResponse(CreateHttpConn(host, path))
507
508
509def GetReview(host, change, revision):
510 """Get review information about a specific revision of a change."""
511 path = 'changes/%s/revisions/%s/review' % (change, revision)
512 return ReadHttpJsonResponse(CreateHttpConn(host, path))
513
514
515def AddReviewers(host, change, add=None):
516 """Add reviewers to a change."""
517 if not add:
518 return
519 if isinstance(add, basestring):
520 add = (add,)
521 path = 'changes/%s/reviewers' % change
522 for r in add:
523 body = {'reviewer': r}
524 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
525 jmsg = ReadHttpJsonResponse(conn, ignore_404=False)
526 return jmsg
527
528
529def RemoveReviewers(host, change, remove=None):
530 """Remove reveiewers from a change."""
531 if not remove:
532 return
533 if isinstance(remove, basestring):
534 remove = (remove,)
535 for r in remove:
536 path = 'changes/%s/reviewers/%s' % (change, r)
537 conn = CreateHttpConn(host, path, reqtype='DELETE')
538 try:
539 ReadHttpResponse(conn, ignore_404=False)
540 except GerritError as e:
541 # On success, gerrit returns status 204; anything else is an error.
542 if e.http_status != 204:
543 raise
544 else:
545 raise GerritError(
546 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
547 ' from change %s' % (r, change))
548
549
550def SetReview(host, change, msg=None, labels=None, notify=None):
551 """Set labels and/or add a message to a code review."""
552 if not msg and not labels:
553 return
554 path = 'changes/%s/revisions/current/review' % change
555 body = {}
556 if msg:
557 body['message'] = msg
558 if labels:
559 body['labels'] = labels
560 if notify:
561 body['notify'] = notify
562 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
563 response = ReadHttpJsonResponse(conn)
564 if labels:
565 for key, val in labels.iteritems():
566 if ('labels' not in response or key not in response['labels'] or
567 int(response['labels'][key] != int(val))):
568 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
569 key, change))
570
571
572def ResetReviewLabels(host, change, label, value='0', message=None,
573 notify=None):
574 """Reset the value of a given label for all reviewers on a change."""
575 # This is tricky, because we want to work on the "current revision", but
576 # there's always the risk that "current revision" will change in between
577 # API calls. So, we check "current revision" at the beginning and end; if
578 # it has changed, raise an exception.
579 jmsg = GetChangeCurrentRevision(host, change)
580 if not jmsg:
581 raise GerritError(
582 200, 'Could not get review information for change "%s"' % change)
583 value = str(value)
584 revision = jmsg[0]['current_revision']
585 path = 'changes/%s/revisions/%s/review' % (change, revision)
586 message = message or (
587 '%s label set to %s programmatically.' % (label, value))
588 jmsg = GetReview(host, change, revision)
589 if not jmsg:
590 raise GerritError(200, 'Could not get review information for revison %s '
591 'of change %s' % (revision, change))
592 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
593 if str(review.get('value', value)) != value:
594 body = {
595 'message': message,
596 'labels': {label: value},
597 'on_behalf_of': review['_account_id'],
598 }
599 if notify:
600 body['notify'] = notify
601 conn = CreateHttpConn(
602 host, path, reqtype='POST', body=body)
603 response = ReadHttpJsonResponse(conn)
604 if str(response['labels'][label]) != value:
605 username = review.get('email', jmsg.get('name', ''))
606 raise GerritError(200, 'Unable to set %s label for user "%s"'
607 ' on change %s.' % (label, username, change))
608 jmsg = GetChangeCurrentRevision(host, change)
609 if not jmsg:
610 raise GerritError(
611 200, 'Could not get review information for change "%s"' % change)
612 elif jmsg[0]['current_revision'] != revision:
613 raise GerritError(200, 'While resetting labels on change "%s", '
614 'a new patchset was uploaded.' % change)