1# -*- coding: utf-8 -*- 2# Copyright (c) 2013 The Chromium OS Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""Utilities for requesting information for a gerrit server via https. 7 8https://gerrit-review.googlesource.com/Documentation/rest-api.html 9""" 10 11from __future__ import print_function 12 13import datetime 14import json 15import os 16import re 17import socket 18import sys 19import warnings 20 21import httplib2 22try: 23 from oauth2client import gce 24except ImportError: # Newer oauth2client versions put it in .contrib 25 # pylint: disable=import-error,no-name-in-module 26 from oauth2client.contrib import gce 27import six 28from six.moves import html_parser as HTMLParser 29from six.moves import http_client as httplib 30from six.moves import http_cookiejar as cookielib 31from six.moves import urllib 32 33from autotest_lib.utils.frozen_chromite.lib import auth 34from autotest_lib.utils.frozen_chromite.lib import constants 35from autotest_lib.utils.frozen_chromite.lib import cros_logging as logging 36from autotest_lib.utils.frozen_chromite.lib import git 37from autotest_lib.utils.frozen_chromite.lib import retry_util 38from autotest_lib.utils.frozen_chromite.lib import timeout_util 39from autotest_lib.utils.frozen_chromite.lib import cros_build_lib 40from autotest_lib.utils.frozen_chromite.utils import memoize 41 42 43_GAE_VERSION = 'GAE_VERSION' 44 45 46class ErrorParser(HTMLParser.HTMLParser): 47 """Class to parse GOB error message reported as HTML. 48 49 Only data inside <div id='af-error-container'> section is retrieved from the 50 GOB error message. Retrieved data is processed as follows: 51 52 - newlines are removed 53 - each <br> tag is replaced with '\n' 54 - each <p> tag is replaced with '\n\n' 55 """ 56 57 def __init__(self): 58 HTMLParser.HTMLParser.__init__(self) 59 self.in_div = False 60 self.err_data = '' 61 62 def handle_starttag(self, tag, attrs): 63 tag_id = [x[1] for x in attrs if x[0] == 'id'] 64 if tag == 'div' and tag_id and tag_id[0] == 'af-error-container': 65 self.in_div = True 66 return 67 68 if self.in_div: 69 if tag == 'p': 70 self.err_data += '\n\n' 71 return 72 73 if tag == 'br': 74 self.err_data += '\n' 75 return 76 77 def handle_endtag(self, tag): 78 if tag == 'div': 79 self.in_div = False 80 81 def handle_data(self, data): 82 if self.in_div: 83 self.err_data += data.replace('\n', '') 84 85 def ParsedDiv(self): 86 return self.err_data.strip() 87 88 89@memoize.Memoize 90def _GetAppCredentials(): 91 """Returns the singleton Appengine credentials for gerrit code review.""" 92 return gce.AppAssertionCredentials( 93 scope='https://www.googleapis.com/auth/gerritcodereview') 94 95 96TRY_LIMIT = 11 97SLEEP = 0.5 98REQUEST_TIMEOUT_SECONDS = 120 # 2 minutes. 99 100# Controls the transport protocol used to communicate with Gerrit servers using 101# git. This is parameterized primarily to enable cros_test_lib.GerritTestCase. 102GIT_PROTOCOL = 'https' 103 104# The GOB conflict errors which could be ignorable. 105GOB_CONFLICT_ERRORS = ( 106 br'change is closed', 107 br'Cannot reduce vote on labels for closed change', 108) 109 110GOB_CONFLICT_ERRORS_RE = re.compile(br'|'.join(GOB_CONFLICT_ERRORS), 111 re.IGNORECASE) 112 113GOB_ERROR_REASON_CLOSED_CHANGE = 'CLOSED CHANGE' 114 115 116class GOBError(Exception): 117 """Exception class for errors commuicating with the gerrit-on-borg service.""" 118 def __init__(self, http_status=None, reason=None): 119 self.http_status = http_status 120 self.reason = reason 121 122 message = '' 123 if http_status is not None: 124 message += '(http_status): %d' % (http_status,) 125 if reason is not None: 126 message += '(reason): %s' % (reason,) 127 if not message: 128 message = 'Unknown error' 129 130 super(GOBError, self).__init__(message) 131 132 133class InternalGOBError(GOBError): 134 """Exception class for GOB errors with status >= 500""" 135 136 137def _QueryString(param_dict, first_param=None): 138 """Encodes query parameters in the key:val[+key:val...] format specified here: 139 140 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes 141 """ 142 q = [urllib.parse.quote(first_param)] if first_param else [] 143 q.extend(['%s:%s' % (key, val) for key, val in param_dict.items()]) 144 return '+'.join(q) 145 146 147def GetCookies(host, path, cookie_paths=None): 148 """Returns cookies that should be set on a request. 149 150 Used by CreateHttpConn for any requests that do not already specify a Cookie 151 header. All requests made by this library are HTTPS. 152 153 Args: 154 host: The hostname of the Gerrit service. 155 path: The path on the Gerrit service, already including /a/ if applicable. 156 cookie_paths: Files to look in for cookies. Defaults to looking in the 157 standard places where GoB places cookies. 158 159 Returns: 160 A dict of cookie name to value, with no URL encoding applied. 161 """ 162 cookies = {} 163 if cookie_paths is None: 164 cookie_paths = (constants.GOB_COOKIE_PATH, constants.GITCOOKIES_PATH) 165 for cookie_path in cookie_paths: 166 if os.path.isfile(cookie_path): 167 with open(cookie_path) as f: 168 for line in f: 169 fields = line.strip().split('\t') 170 if line.strip().startswith('#') or len(fields) != 7: 171 continue 172 domain, xpath, key, value = fields[0], fields[2], fields[5], fields[6] 173 if cookielib.domain_match(host, domain) and path.startswith(xpath): 174 cookies[key] = value 175 return cookies 176 177 178def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None): 179 """Opens an https connection to a gerrit service, and sends a request.""" 180 path = '/a/' + path.lstrip('/') 181 headers = headers or {} 182 if _InAppengine(): 183 # TODO(phobbs) how can we choose to only run this on GCE / AppEngine? 184 credentials = _GetAppCredentials() 185 try: 186 headers.setdefault( 187 'Authorization', 188 'Bearer %s' % credentials.get_access_token().access_token) 189 except gce.HttpAccessTokenRefreshError as e: 190 logging.debug('Failed to retreive gce access token: %s', e) 191 # Not in an Appengine or GCE environment. 192 except httplib2.ServerNotFoundError as e: 193 pass 194 195 cookies = GetCookies(host, path) 196 if 'Cookie' not in headers and cookies: 197 headers['Cookie'] = '; '.join('%s=%s' % (n, v) for n, v in cookies.items()) 198 elif 'Authorization' not in headers: 199 try: 200 git_creds = auth.GitCreds() 201 except auth.AccessTokenError: 202 git_creds = None 203 if git_creds: 204 headers.setdefault('Authorization', 'Bearer %s' % git_creds) 205 else: 206 logging.debug( 207 'No gitcookies file, Appengine credentials, or LUCI git creds found.') 208 209 if 'User-Agent' not in headers: 210 # We may not be in a git repository. 211 try: 212 version = git.GetGitRepoRevision( 213 os.path.dirname(os.path.realpath(__file__))) 214 except cros_build_lib.RunCommandError: 215 version = 'unknown' 216 headers['User-Agent'] = ' '.join(( 217 'autotest.chromite.lib.gob_util', 218 os.path.basename(sys.argv[0]), 219 version, 220 )) 221 222 if body: 223 body = json.JSONEncoder().encode(body) 224 headers.setdefault('Content-Type', 'application/json') 225 if logging.getLogger().isEnabledFor(logging.DEBUG): 226 logging.debug('%s https://%s%s', reqtype, host, path) 227 for key, val in headers.items(): 228 if key.lower() in ('authorization', 'cookie'): 229 val = 'HIDDEN' 230 logging.debug('%s: %s', key, val) 231 if body: 232 logging.debug(body) 233 conn = httplib.HTTPSConnection(host) 234 conn.req_host = host 235 conn.req_params = { 236 'url': path, 237 'method': reqtype, 238 'headers': headers, 239 'body': body, 240 } 241 conn.request(**conn.req_params) 242 return conn 243 244 245def _InAppengine(): 246 """Returns whether we're in the Appengine environment.""" 247 return _GAE_VERSION in os.environ 248 249 250def FetchUrl(host, path, reqtype='GET', headers=None, body=None, 251 ignore_204=False, ignore_404=True): 252 """Fetches the http response from the specified URL. 253 254 Args: 255 host: The hostname of the Gerrit service. 256 path: The path on the Gerrit service. This will be prefixed with '/a' 257 automatically. 258 reqtype: The request type. Can be GET or POST. 259 headers: A mapping of extra HTTP headers to pass in with the request. 260 body: A string of data to send after the headers are finished. 261 ignore_204: for some requests gerrit-on-borg will return 204 to confirm 262 proper processing of the request. When processing responses to 263 these requests we should expect this status. 264 ignore_404: For many requests, gerrit-on-borg will return 404 if the request 265 doesn't match the database contents. In most such cases, we 266 want the API to return None rather than raise an Exception. 267 268 Returns: 269 The connection's reply, as bytes. 270 """ 271 @timeout_util.TimeoutDecorator(REQUEST_TIMEOUT_SECONDS) 272 def _FetchUrlHelper(): 273 err_prefix = 'A transient error occured while querying %s:\n' % (host,) 274 try: 275 conn = CreateHttpConn(host, path, reqtype=reqtype, headers=headers, 276 body=body) 277 response = conn.getresponse() 278 except socket.error as ex: 279 logging.warning('%s%s', err_prefix, str(ex)) 280 raise 281 282 # Normal/good responses. 283 response_body = response.read() 284 if response.status == 204 and ignore_204: 285 # This exception is used to confirm expected response status. 286 raise GOBError(http_status=response.status, reason=response.reason) 287 if response.status == 404 and ignore_404: 288 return b'' 289 elif response.status == 200: 290 return response_body 291 292 # Bad responses. 293 logging.debug('response msg:\n%s', response.msg) 294 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0') 295 msg = ('%s %s %s\n%s %d %s\nResponse body: %r' % 296 (reqtype, conn.req_params['url'], http_version, 297 http_version, response.status, response.reason, 298 response_body)) 299 300 # Ones we can retry. 301 if response.status >= 500: 302 # A status >=500 is assumed to be a possible transient error; retry. 303 logging.warning('%s%s', err_prefix, msg) 304 raise InternalGOBError( 305 http_status=response.status, 306 reason=response.reason) 307 308 # Ones we cannot retry. 309 home = os.environ.get('HOME', '~') 310 url = 'https://%s/new-password' % host 311 if response.status in (302, 303, 307): 312 err_prefix = ('Redirect found; missing/bad %s/.gitcookies credentials or ' 313 'permissions (0600)?\n See %s' % (home, url)) 314 elif response.status in (400,): 315 err_prefix = 'Permission error; talk to the admins of the GoB instance' 316 elif response.status in (401,): 317 err_prefix = ('Authorization error; missing/bad %s/.gitcookies ' 318 'credentials or permissions (0600)?\n See %s' % (home, url)) 319 elif response.status in (422,): 320 err_prefix = ('Bad request body?') 321 322 logging.warning(err_prefix) 323 324 # If GOB output contained expected error message, reduce log visibility of 325 # raw GOB output reported below. 326 ep = ErrorParser() 327 ep.feed(response_body.decode('utf-8')) 328 ep.close() 329 parsed_div = ep.ParsedDiv() 330 if parsed_div: 331 logging.warning('GOB Error:\n%s', parsed_div) 332 logging_function = logging.debug 333 else: 334 logging_function = logging.warning 335 336 logging_function(msg) 337 if response.status >= 400: 338 # The 'X-ErrorId' header is set only on >= 400 response code. 339 logging_function('X-ErrorId: %s', response.getheader('X-ErrorId')) 340 341 try: 342 logging.warning('conn.sock.getpeername(): %s', conn.sock.getpeername()) 343 except AttributeError: 344 logging.warning('peer name unavailable') 345 346 if response.status == httplib.CONFLICT: 347 # 409 conflict 348 if GOB_CONFLICT_ERRORS_RE.search(response_body): 349 raise GOBError( 350 http_status=response.status, 351 reason=GOB_ERROR_REASON_CLOSED_CHANGE) 352 else: 353 raise GOBError(http_status=response.status, reason=response.reason) 354 else: 355 raise GOBError(http_status=response.status, reason=response.reason) 356 357 return retry_util.RetryException( 358 (socket.error, InternalGOBError, timeout_util.TimeoutError), 359 TRY_LIMIT, 360 _FetchUrlHelper, sleep=SLEEP, backoff_factor=2) 361 362 363def FetchUrlJson(*args, **kwargs): 364 """Fetch the specified URL and parse it as JSON. 365 366 See FetchUrl for arguments. 367 """ 368 fh = FetchUrl(*args, **kwargs) 369 370 # In case ignore_404 is True, we want to return None instead of 371 # raising an exception. 372 if not fh: 373 return None 374 375 # The first line of the response should always be: )]}' 376 if not fh.startswith(b")]}'"): 377 raise GOBError(http_status=200, reason='Unexpected json output: %r' % fh) 378 379 _, _, json_data = fh.partition(b'\n') 380 return json.loads(json_data) 381 382 383def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None, 384 start=None): 385 """Queries a gerrit-on-borg server for changes matching query terms. 386 387 Args: 388 host: The Gerrit server hostname. 389 param_dict: A dictionary of search parameters, as documented here: 390 https://gerrit-review.googlesource.com/Documentation/user-search.html 391 first_param: A change identifier 392 limit: Maximum number of results to return. 393 o_params: A list of additional output specifiers, as documented here: 394 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes 395 start: Offset in the result set to start at. 396 397 Returns: 398 A list of json-decoded query results. 399 """ 400 # Note that no attempt is made to escape special characters; YMMV. 401 if not param_dict and not first_param: 402 raise RuntimeError('QueryChanges requires search parameters') 403 path = 'changes/?q=%s' % _QueryString(param_dict, first_param) 404 if start: 405 path = '%s&S=%d' % (path, start) 406 if limit: 407 path = '%s&n=%d' % (path, limit) 408 if o_params: 409 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params])) 410 # Don't ignore 404; a query should always return a list, even if it's empty. 411 return FetchUrlJson(host, path, ignore_404=False) 412 413 414def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None, 415 start=None): 416 """Initiate a query composed of multiple sets of query parameters.""" 417 if not change_list: 418 raise RuntimeError( 419 "MultiQueryChanges requires a list of change numbers/id's") 420 q = ['q=%s' % '+OR+'.join(urllib.parse.quote(str(x)) for x in change_list)] 421 if param_dict: 422 q.append(_QueryString(param_dict)) 423 if limit: 424 q.append('n=%d' % limit) 425 if start: 426 q.append('S=%s' % start) 427 if o_params: 428 q.extend(['o=%s' % p for p in o_params]) 429 path = 'changes/?%s' % '&'.join(q) 430 try: 431 result = FetchUrlJson(host, path, ignore_404=False) 432 except GOBError as e: 433 msg = '%s:\n%s' % (e, path) 434 raise GOBError(http_status=e.http_status, reason=msg) 435 return result 436 437 438def GetGerritFetchUrl(host): 439 """Given a gerrit host name returns URL of a gerrit instance to fetch from.""" 440 return 'https://%s/' % host 441 442 443def GetChangePageUrl(host, change_number): 444 """Given a gerrit host name and change number, return change page url.""" 445 return 'https://%s/#/c/%d/' % (host, change_number) 446 447 448def _GetChangePath(change): 449 """Given a change id, return a path prefix for the change.""" 450 return 'changes/%s' % str(change).replace('/', '%2F') 451 452 453def GetChangeUrl(host, change): 454 """Given a gerrit host name and change id, return an url for the change.""" 455 return 'https://%s/a/%s' % (host, _GetChangePath(change)) 456 457 458def GetChange(host, change): 459 """Query a gerrit server for information about a single change.""" 460 return FetchUrlJson(host, _GetChangePath(change)) 461 462 463def GetChangeReview(host, change, revision=None): 464 """Get the current review information for a change.""" 465 if revision is None: 466 revision = 'current' 467 path = '%s/revisions/%s/review' % (_GetChangePath(change), revision) 468 return FetchUrlJson(host, path) 469 470 471def GetChangeCommit(host, change, revision=None): 472 """Get the current review information for a change.""" 473 if revision is None: 474 revision = 'current' 475 path = '%s/revisions/%s/commit' % (_GetChangePath(change), revision) 476 return FetchUrlJson(host, path) 477 478 479def GetChangeCurrentRevision(host, change): 480 """Get information about the latest revision for a given change.""" 481 jmsg = GetChangeReview(host, change) 482 if jmsg: 483 return jmsg.get('current_revision') 484 485 486def GetChangeDetail(host, change, o_params=None): 487 """Query a gerrit server for extended information about a single change.""" 488 path = '%s/detail' % _GetChangePath(change) 489 if o_params: 490 path = '%s?%s' % (path, '&'.join(['o=%s' % p for p in o_params])) 491 return FetchUrlJson(host, path) 492 493 494def GetChangeReviewers(host, change): 495 """Get information about all reviewers attached to a change. 496 497 Args: 498 host: The Gerrit host to interact with. 499 change: The Gerrit change ID. 500 """ 501 warnings.warn('GetChangeReviewers is deprecated; use GetReviewers instead.') 502 GetReviewers(host, change) 503 504 505def ReviewedChange(host, change): 506 """Mark a gerrit change as reviewed.""" 507 path = '%s/reviewed' % _GetChangePath(change) 508 return FetchUrlJson(host, path, reqtype='PUT', ignore_404=False) 509 510 511def UnreviewedChange(host, change): 512 """Mark a gerrit change as unreviewed.""" 513 path = '%s/unreviewed' % _GetChangePath(change) 514 return FetchUrlJson(host, path, reqtype='PUT', ignore_404=False) 515 516 517def IgnoreChange(host, change): 518 """Ignore a gerrit change.""" 519 path = '%s/ignore' % _GetChangePath(change) 520 return FetchUrlJson(host, path, reqtype='PUT', ignore_404=False) 521 522 523def UnignoreChange(host, change): 524 """Unignore a gerrit change.""" 525 path = '%s/unignore' % _GetChangePath(change) 526 return FetchUrlJson(host, path, reqtype='PUT', ignore_404=False) 527 528 529def AbandonChange(host, change, msg=''): 530 """Abandon a gerrit change.""" 531 path = '%s/abandon' % _GetChangePath(change) 532 body = {'message': msg} 533 return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False) 534 535 536def RestoreChange(host, change, msg=''): 537 """Restore a previously abandoned change.""" 538 path = '%s/restore' % _GetChangePath(change) 539 body = {'message': msg} 540 return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False) 541 542 543def DeleteDraft(host, change): 544 """Delete a gerrit draft change.""" 545 path = _GetChangePath(change) 546 try: 547 FetchUrl(host, path, reqtype='DELETE', ignore_204=True, ignore_404=False) 548 except GOBError as e: 549 # On success, gerrit returns status 204; anything else is an error. 550 if e.http_status != 204: 551 raise 552 else: 553 raise GOBError( 554 http_status=200, 555 reason='Unexpectedly received a 200 http status while deleting draft ' 556 ' %r' % change) 557 558 559def SubmitChange(host, change, revision=None, wait_for_merge=True): 560 """Submits a gerrit change via Gerrit.""" 561 if revision is None: 562 revision = 'current' 563 path = '%s/revisions/%s/submit' % (_GetChangePath(change), revision) 564 body = {'wait_for_merge': wait_for_merge} 565 return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False) 566 567 568def CheckChange(host, change, sha1=None): 569 """Performs consistency checks on the change, and fixes inconsistencies. 570 571 This is useful for forcing Gerrit to check whether a change has already been 572 merged into the git repo. Namely, if |sha1| is provided and the change is in 573 'NEW' status, Gerrit will check if a change with that |sha1| is in the repo 574 and mark the change as 'MERGED' if it exists. 575 576 Args: 577 host: The Gerrit host to interact with. 578 change: The Gerrit change ID. 579 sha1: An optional hint of the commit's SHA1 in Git. 580 """ 581 path = '%s/check' % (_GetChangePath(change),) 582 if sha1: 583 body, headers = {'expect_merged_as': sha1}, {} 584 else: 585 body, headers = {}, {'Content-Length': '0'} 586 587 return FetchUrlJson(host, path, reqtype='POST', 588 body=body, ignore_404=False, 589 headers=headers) 590 591 592def GetAssignee(host, change): 593 """Get assignee for a change.""" 594 path = '%s/assignee' % _GetChangePath(change) 595 return FetchUrlJson(host, path) 596 597 598def AddAssignee(host, change, assignee): 599 """Add reviewers to a change. 600 601 Args: 602 host: The Gerrit host to interact with. 603 change: The Gerrit change ID. 604 assignee: Gerrit account email as a string 605 """ 606 path = '%s/assignee' % _GetChangePath(change) 607 body = {'assignee': assignee} 608 return FetchUrlJson(host, path, reqtype='PUT', body=body, ignore_404=False) 609 610 611def MarkPrivate(host, change): 612 """Marks the given CL as private. 613 614 Args: 615 host: The gob host to interact with. 616 change: CL number on the given host. 617 """ 618 path = '%s/private' % _GetChangePath(change) 619 try: 620 FetchUrlJson(host, path, reqtype='POST', ignore_404=False) 621 except GOBError as e: 622 # 201: created -- change was successfully marked private. 623 if e.http_status != 201: 624 raise 625 else: 626 raise GOBError( 627 http_status=200, 628 reason='Change was already marked private', 629 ) 630 631 632def MarkNotPrivate(host, change): 633 """Sets the private bit on given CL to False. 634 635 Args: 636 host: The gob host to interact with. 637 change: CL number on the given host. 638 """ 639 path = '%s/private.delete' % _GetChangePath(change) 640 try: 641 FetchUrlJson(host, path, reqtype='POST', ignore_404=False, ignore_204=True) 642 except GOBError as e: 643 if e.http_status == 204: 644 # 204: no content -- change was successfully marked not private. 645 pass 646 elif e.http_status == 409: 647 raise GOBError( 648 http_status=e.http_status, 649 reason='Change was already marked not private', 650 ) 651 else: 652 raise 653 else: 654 raise GOBError( 655 http_status=200, 656 reason='Got unexpected 200 when marking change not private.', 657 ) 658 659 660def GetReviewers(host, change): 661 """Get information about all reviewers attached to a change. 662 663 Args: 664 host: The Gerrit host to interact with. 665 change: The Gerrit change ID. 666 """ 667 path = '%s/reviewers' % _GetChangePath(change) 668 return FetchUrlJson(host, path) 669 670 671def AddReviewers(host, change, add=None, notify=None): 672 """Add reviewers to a change.""" 673 if not add: 674 return 675 if isinstance(add, six.string_types): 676 add = (add,) 677 body = {} 678 if notify: 679 body['notify'] = notify 680 path = '%s/reviewers' % _GetChangePath(change) 681 for r in add: 682 body['reviewer'] = r 683 jmsg = FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False) 684 return jmsg 685 686 687def RemoveReviewers(host, change, remove=None, notify=None): 688 """Remove reveiewers from a change.""" 689 if not remove: 690 return 691 if isinstance(remove, six.string_types): 692 remove = (remove,) 693 body = {} 694 if notify: 695 body['notify'] = notify 696 for r in remove: 697 path = '%s/reviewers/%s/delete' % (_GetChangePath(change), r) 698 try: 699 FetchUrl(host, path, reqtype='POST', body=body, ignore_204=True) 700 except GOBError as e: 701 # On success, gerrit returns status 204; anything else is an error. 702 if e.http_status != 204: 703 raise 704 705 706def SetReview(host, change, revision=None, msg=None, labels=None, notify=None): 707 """Set labels and/or add a message to a code review.""" 708 if revision is None: 709 revision = 'current' 710 if not msg and not labels: 711 return 712 path = '%s/revisions/%s/review' % (_GetChangePath(change), revision) 713 body = {} 714 if msg: 715 body['message'] = msg 716 if labels: 717 body['labels'] = labels 718 if notify: 719 body['notify'] = notify 720 response = FetchUrlJson(host, path, reqtype='POST', body=body) 721 if response is None: 722 raise GOBError( 723 http_status=404, 724 reason='CL %s not found in %s' % (change, host)) 725 if labels: 726 for key, val in labels.items(): 727 if ('labels' not in response or key not in response['labels'] or 728 int(response['labels'][key] != int(val))): 729 raise GOBError( 730 http_status=200, 731 reason='Unable to set "%s" label on change %s.' % (key, change)) 732 733 734def SetTopic(host, change, topic): 735 """Set |topic| for a change. If |topic| is empty, it will be deleted""" 736 path = '%s/topic' % _GetChangePath(change) 737 body = {'topic': topic} 738 return FetchUrlJson(host, path, reqtype='PUT', body=body, ignore_404=False) 739 740 741def SetHashtags(host, change, add, remove): 742 """Adds and / or removes hashtags from a change. 743 744 Args: 745 host: Hostname (without protocol prefix) of the gerrit server. 746 change: A gerrit change number. 747 add: a list of hashtags to be added. 748 remove: a list of hashtags to be removed. 749 """ 750 path = '%s/hashtags' % _GetChangePath(change) 751 return FetchUrlJson(host, path, reqtype='POST', 752 body={'add': add, 'remove': remove}, 753 ignore_404=False) 754 755 756def ResetReviewLabels(host, change, label, value='0', revision=None, 757 message=None, notify=None): 758 """Reset the value of a given label for all reviewers on a change.""" 759 if revision is None: 760 revision = 'current' 761 # This is tricky when working on the "current" revision, because there's 762 # always the risk that the "current" revision will change in between API 763 # calls. So, the code dereferences the "current" revision down to a literal 764 # sha1 at the beginning and uses it for all subsequent calls. As a sanity 765 # check, the "current" revision is dereferenced again at the end, and if it 766 # differs from the previous "current" revision, an exception is raised. 767 current = (revision == 'current') 768 jmsg = GetChangeDetail( 769 host, change, o_params=['CURRENT_REVISION', 'CURRENT_COMMIT']) 770 if current: 771 revision = jmsg['current_revision'] 772 value = str(value) 773 path = '%s/revisions/%s/review' % (_GetChangePath(change), revision) 774 message = message or ( 775 '%s label set to %s programmatically by chromite.' % (label, value)) 776 for review in jmsg.get('labels', {}).get(label, {}).get('all', []): 777 if str(review.get('value', value)) != value: 778 body = { 779 'message': message, 780 'labels': {label: value}, 781 'on_behalf_of': review['_account_id'], 782 } 783 if notify: 784 body['notify'] = notify 785 response = FetchUrlJson(host, path, reqtype='POST', body=body) 786 if str(response['labels'][label]) != value: 787 username = review.get('email', jmsg.get('name', '')) 788 raise GOBError( 789 http_status=200, 790 reason='Unable to set %s label for user "%s" on change %s.' % ( 791 label, username, change)) 792 if current: 793 new_revision = GetChangeCurrentRevision(host, change) 794 if not new_revision: 795 raise GOBError( 796 http_status=200, 797 reason='Could not get review information for change "%s"' % change) 798 elif new_revision != revision: 799 raise GOBError( 800 http_status=200, 801 reason='While resetting labels on change "%s", a new patchset was ' 802 'uploaded.' % change) 803 804 805def GetTipOfTrunkRevision(git_url): 806 """Returns the current git revision on the master branch.""" 807 parsed_url = urllib.parse.urlparse(git_url) 808 path = parsed_url[2].rstrip('/') + '/+log/master?n=1&format=JSON' 809 j = FetchUrlJson(parsed_url[1], path, ignore_404=False) 810 if not j: 811 raise GOBError( 812 reason='Could not find revision information from %s' % git_url) 813 try: 814 return j['log'][0]['commit'] 815 except (IndexError, KeyError, TypeError): 816 msg = ('The json returned by https://%s%s has an unfamiliar structure:\n' 817 '%s\n' % (parsed_url[1], path, j)) 818 raise GOBError(reason=msg) 819 820 821def GetCommitDate(git_url, commit): 822 """Returns the date of a particular git commit. 823 824 The returned object is naive in the sense that it doesn't carry any timezone 825 information - you should assume UTC. 826 827 Args: 828 git_url: URL for the repository to get the commit date from. 829 commit: A git commit identifier (e.g. a sha1). 830 831 Returns: 832 A datetime object. 833 """ 834 parsed_url = urllib.parse.urlparse(git_url) 835 path = '%s/+log/%s?n=1&format=JSON' % (parsed_url.path.rstrip('/'), commit) 836 j = FetchUrlJson(parsed_url.netloc, path, ignore_404=False) 837 if not j: 838 raise GOBError( 839 reason='Could not find revision information from %s' % git_url) 840 try: 841 commit_timestr = j['log'][0]['committer']['time'] 842 except (IndexError, KeyError, TypeError): 843 msg = ('The json returned by https://%s%s has an unfamiliar structure:\n' 844 '%s\n' % (parsed_url.netloc, path, j)) 845 raise GOBError(reason=msg) 846 try: 847 # We're parsing a string of the form 'Tue Dec 02 17:48:06 2014'. 848 return datetime.datetime.strptime(commit_timestr, 849 constants.GOB_COMMIT_TIME_FORMAT) 850 except ValueError: 851 raise GOBError(reason='Failed parsing commit time "%s"' % commit_timestr) 852 853 854def GetAccount(host): 855 """Get information about the user account.""" 856 return FetchUrlJson(host, 'accounts/self') 857