rietveld.py 26.4 KB
Newer Older
1
# coding: utf-8
2
# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3 4 5 6 7 8 9 10 11 12 13 14 15 16
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Defines class Rietveld to easily access a rietveld instance.

Security implications:

The following hypothesis are made:
- Rietveld enforces:
  - Nobody else than issue owner can upload a patch set
  - Verifies the issue owner credentials when creating new issues
  - A issue owner can't change once the issue is created
  - A patch set cannot be modified
"""

17
import copy
18
import errno
19
import json
20
import logging
21
import re
22
import socket
23
import ssl
24
import StringIO
25
import sys
26
import time
27
import urllib
28
import urllib2
29
import urlparse
30 31 32

import patch

33 34 35 36
from third_party import upload
import third_party.oauth2client.client as oa2client
from third_party import httplib2

37 38
# Appengine replies with 302 when authentication fails (sigh.)
oa2client.REFRESH_STATUS_CODES.append(302)
39
upload.LOGGER.setLevel(logging.WARNING)  # pylint: disable=E1103
40 41 42 43


class Rietveld(object):
  """Accesses rietveld."""
44 45
  def __init__(
      self, url, auth_config, email=None, extra_headers=None, maxtries=None):
46
    self.url = url.rstrip('/')
47
    self.rpc_server = upload.GetRpcServer(self.url, auth_config, email)
48

49 50 51
    self._xsrf_token = None
    self._xsrf_token_time = None

52 53
    self._maxtries = maxtries or 40

54 55 56 57 58 59 60 61 62 63 64
  def xsrf_token(self):
    if (not self._xsrf_token_time or
        (time.time() - self._xsrf_token_time) > 30*60):
      self._xsrf_token_time = time.time()
      self._xsrf_token = self.get(
          '/xsrf_token',
          extra_headers={'X-Requesting-XSRF-Token': '1'})
    return self._xsrf_token

  def get_pending_issues(self):
    """Returns an array of dict of all the pending issues on the server."""
65 66 67 68
    # TODO: Convert this to use Rietveld::search(), defined below.
    return json.loads(
        self.get('/search?format=json&commit=2&closed=3&'
                 'keys_only=True&limit=1000&order=__key__'))['results']
69 70 71

  def close_issue(self, issue):
    """Closes the Rietveld issue for this changelist."""
72
    logging.info('closing issue %d' % issue)
73 74 75
    self.post("/%d/close" % issue, [('xsrf_token', self.xsrf_token())])

  def get_description(self, issue):
76 77 78 79 80
    """Returns the issue's description.

    Converts any CRLF into LF and strip extraneous whitespace.
    """
    return '\n'.join(self.get('/%d/description' % issue).strip().splitlines())
81 82 83

  def get_issue_properties(self, issue, messages):
    """Returns all the issue's metadata as a dictionary."""
84
    url = '/api/%d' % issue
85 86
    if messages:
      url += '?messages=true'
87
    data = json.loads(self.get(url, retry_on_404=True))
88 89
    data['description'] = '\n'.join(data['description'].strip().splitlines())
    return data
90

91 92 93 94 95
  def get_depends_on_patchset(self, issue, patchset):
    """Returns the patchset this patchset depends on if it exists."""
    url = '/%d/patchset/%d/get_depends_on_patchset' % (issue, patchset)
    resp = None
    try:
96
      resp = json.loads(self.post(url, []))
97 98 99 100 101 102 103 104
    except (urllib2.HTTPError, ValueError):
      # The get_depends_on_patchset endpoint does not exist on this Rietveld
      # instance yet. Ignore the error and proceed.
      # TODO(rmistry): Make this an error when all Rietveld instances have
      # this endpoint.
      pass
    return resp

105 106
  def get_patchset_properties(self, issue, patchset):
    """Returns the patchset properties."""
107
    url = '/api/%d/%d' % (issue, patchset)
108 109 110 111 112 113 114 115 116
    return json.loads(self.get(url))

  def get_file_content(self, issue, patchset, item):
    """Returns the content of a new file.

    Throws HTTP 302 exception if the file doesn't exist or is not a binary file.
    """
    # content = 0 is the old file, 1 is the new file.
    content = 1
117
    url = '/%d/binary/%d/%d/%d' % (issue, patchset, item, content)
118 119 120 121 122 123 124
    return self.get(url)

  def get_file_diff(self, issue, patchset, item):
    """Returns the diff of the file.

    Returns a useless diff for binary files.
    """
125
    url = '/download/issue%d_%d_%d.diff' % (issue, patchset, item)
126 127 128 129 130 131 132
    return self.get(url)

  def get_patch(self, issue, patchset):
    """Returns a PatchSet object containing the details to apply this patch."""
    props = self.get_patchset_properties(issue, patchset) or {}
    out = []
    for filename, state in props.get('files', {}).iteritems():
133
      logging.debug('%s' % filename)
134 135 136
      # If not status, just assume it's a 'M'. Rietveld often gets it wrong and
      # just has status: null. Oh well.
      status = state.get('status') or 'M'
137
      if status[0] not in ('A', 'D', 'M', 'R'):
138 139
        raise patch.UnsupportedPatchFormat(
            filename, 'Change with status \'%s\' is not supported.' % status)
140

141 142 143 144 145 146 147 148 149 150
      svn_props = self.parse_svn_properties(
          state.get('property_changes', ''), filename)

      if state.get('is_binary'):
        if status[0] == 'D':
          if status[0] != status.strip():
            raise patch.UnsupportedPatchFormat(
                filename, 'Deleted file shouldn\'t have property change.')
          out.append(patch.FilePatchDelete(filename, state['is_binary']))
        else:
151
          content = self.get_file_content(issue, patchset, state['id'])
152
          if not content or content == 'None':
153 154 155 156 157 158
            # As a precaution due to a bug in upload.py for git checkout, refuse
            # empty files. If it's empty, it's not a binary file.
            raise patch.UnsupportedPatchFormat(
                filename,
                'Binary file is empty. Maybe the file wasn\'t uploaded in the '
                'first place?')
159
          out.append(patch.FilePatchBinary(
160
              filename,
161 162 163
              content,
              svn_props,
              is_new=(status[0] == 'A')))
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182
        continue

      try:
        diff = self.get_file_diff(issue, patchset, state['id'])
      except urllib2.HTTPError, e:
        if e.code == 404:
          raise patch.UnsupportedPatchFormat(
              filename, 'File doesn\'t have a diff.')
        raise

      # FilePatchDiff() will detect file deletion automatically.
      p = patch.FilePatchDiff(filename, diff, svn_props)
      out.append(p)
      if status[0] == 'A':
        # It won't be set for empty file.
        p.is_new = True
      if (len(status) > 1 and
          status[1] == '+' and
          not (p.source_filename or p.svn_properties)):
183
        raise patch.UnsupportedPatchFormat(
184
            filename, 'Failed to process the svn properties')
185

186 187
    return patch.PatchSet(out)

188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
  @staticmethod
  def parse_svn_properties(rietveld_svn_props, filename):
    """Returns a list of tuple [('property', 'newvalue')].

    rietveld_svn_props is the exact format from 'svn diff'.
    """
    rietveld_svn_props = rietveld_svn_props.splitlines()
    svn_props = []
    if not rietveld_svn_props:
      return svn_props
    # 1. Ignore svn:mergeinfo.
    # 2. Accept svn:eol-style and svn:executable.
    # 3. Refuse any other.
    # \n
    # Added: svn:ignore\n
    #    + LF\n

205 206 207 208 209 210 211 212
    spacer = rietveld_svn_props.pop(0)
    if spacer or not rietveld_svn_props:
      # svn diff always put a spacer between the unified diff and property
      # diff
      raise patch.UnsupportedPatchFormat(
          filename, 'Failed to parse svn properties.')

    while rietveld_svn_props:
213 214 215 216 217 218
      # Something like 'Added: svn:eol-style'. Note the action is localized.
      # *sigh*.
      action = rietveld_svn_props.pop(0)
      match = re.match(r'^(\w+): (.+)$', action)
      if not match or not rietveld_svn_props:
        raise patch.UnsupportedPatchFormat(
219 220
            filename,
            'Failed to parse svn properties: %s, %s' % (action, svn_props))
221 222 223 224 225 226 227 228 229 230 231

      if match.group(2) == 'svn:mergeinfo':
        # Silently ignore the content.
        rietveld_svn_props.pop(0)
        continue

      if match.group(1) not in ('Added', 'Modified'):
        # Will fail for our French friends.
        raise patch.UnsupportedPatchFormat(
            filename, 'Unsupported svn property operation.')

232
      if match.group(2) in ('svn:eol-style', 'svn:executable', 'svn:mime-type'):
233 234 235 236 237 238 239 240 241
        # '   + foo' where foo is the new value. That's fragile.
        content = rietveld_svn_props.pop(0)
        match2 = re.match(r'^   \+ (.*)$', content)
        if not match2:
          raise patch.UnsupportedPatchFormat(
              filename, 'Unsupported svn property format.')
        svn_props.append((match.group(2), match2.group(1)))
    return svn_props

242 243
  def update_description(self, issue, description):
    """Sets the description for an issue on Rietveld."""
244 245
    logging.info('new description for issue %d' % issue)
    self.post('/%d/description' % issue, [
246 247 248
        ('description', description),
        ('xsrf_token', self.xsrf_token())])

249
  def add_comment(self, issue, message, add_as_reviewer=False):
250 251 252 253
    max_message = 10000
    tail = '…\n(message too large)'
    if len(message) > max_message:
      message = message[:max_message-len(tail)] + tail
254
    logging.info('issue %d; comment: %s' % (issue, message.strip()[:300]))
255
    return self.post('/%d/publish' % issue, [
256 257 258
        ('xsrf_token', self.xsrf_token()),
        ('message', message),
        ('message_only', 'True'),
259
        ('add_as_reviewer', str(bool(add_as_reviewer))),
260 261 262
        ('send_mail', 'True'),
        ('no_redirect', 'True')])

263 264 265 266 267 268 269 270 271 272 273 274
  def add_inline_comment(
      self, issue, text, side, snapshot, patchset, patchid, lineno):
    logging.info('add inline comment for issue %d' % issue)
    return self.post('/inline_draft', [
        ('issue', str(issue)),
        ('text', text),
        ('side', side),
        ('snapshot', snapshot),
        ('patchset', str(patchset)),
        ('patch', str(patchid)),
         ('lineno', str(lineno))])

275
  def set_flag(self, issue, patchset, flag, value):
276
    return self.post('/%d/edit_flags' % issue, [
277 278
        ('last_patchset', str(patchset)),
        ('xsrf_token', self.xsrf_token()),
279
        (flag, str(value))])
280

281 282 283 284 285 286
  def set_flags(self, issue, patchset, flags):
    return self.post('/%d/edit_flags' % issue, [
        ('last_patchset', str(patchset)),
        ('xsrf_token', self.xsrf_token()),
        ] + [(flag, str(value)) for flag, value in flags.iteritems()])

287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322
  def search(
      self,
      owner=None, reviewer=None,
      base=None,
      closed=None, private=None, commit=None,
      created_before=None, created_after=None,
      modified_before=None, modified_after=None,
      per_request=None, keys_only=False,
      with_messages=False):
    """Yields search results."""
    # These are expected to be strings.
    string_keys = {
        'owner': owner,
        'reviewer': reviewer,
        'base': base,
        'created_before': created_before,
        'created_after': created_after,
        'modified_before': modified_before,
        'modified_after': modified_after,
    }
    # These are either None, False or True.
    three_state_keys = {
      'closed': closed,
      'private': private,
      'commit': commit,
    }

    url = '/search?format=json'
    # Sort the keys mainly to ease testing.
    for key in sorted(string_keys):
      value = string_keys[key]
      if value:
        url += '&%s=%s' % (key, urllib2.quote(value))
    for key in sorted(three_state_keys):
      value = three_state_keys[key]
      if value is not None:
323
        url += '&%s=%s' % (key, value)
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344

    if keys_only:
      url += '&keys_only=True'
    if with_messages:
      url += '&with_messages=True'
    if per_request:
      url += '&limit=%d' % per_request

    cursor = ''
    while True:
      output = self.get(url + cursor)
      if output.startswith('<'):
        # It's an error message. Return as no result.
        break
      data = json.loads(output) or {}
      if not data.get('results'):
        break
      for i in data['results']:
        yield i
      cursor = '&cursor=%s' % data['cursor']

345
  def trigger_try_jobs(
346
      self, issue, patchset, reason, clobber, revision, builders_and_tests,
347
      master=None, category='cq'):
348 349 350
    """Requests new try jobs.

    |builders_and_tests| is a map of builders: [tests] to run.
351
    |master| is the name of the try master the builders belong to.
352
    |category| is used to distinguish regular jobs and experimental jobs.
353 354 355 356 357 358 359 360

    Returns the keys of the new TryJobResult entites.
    """
    params = [
      ('reason', reason),
      ('clobber', 'True' if clobber else 'False'),
      ('builders', json.dumps(builders_and_tests)),
      ('xsrf_token', self.xsrf_token()),
361
      ('category', category),
362
    ]
363 364
    if revision:
      params.append(('revision', revision))
365 366 367 368 369
    if master:
      # Temporarily allow empty master names for old configurations. The try
      # job will not be associated with a master name on rietveld. This is
      # going to be deprecated.
      params.append(('master', master))
370 371
    return self.post('/%d/try/%d' % (issue, patchset), params)

372
  def trigger_distributed_try_jobs(
373 374
      self, issue, patchset, reason, clobber, revision, masters,
      category='cq'):
375 376 377
    """Requests new try jobs.

    |masters| is a map of masters: map of builders: [tests] to run.
378
    |category| is used to distinguish regular jobs and experimental jobs.
379 380 381 382
    """
    for (master, builders_and_tests) in masters.iteritems():
      self.trigger_try_jobs(
          issue, patchset, reason, clobber, revision, builders_and_tests,
383
          master, category)
384

385 386 387 388 389 390 391 392 393 394
  def get_pending_try_jobs(self, cursor=None, limit=100):
    """Retrieves the try job requests in pending state.

    Returns a tuple of the list of try jobs and the cursor for the next request.
    """
    url = '/get_pending_try_patchsets?limit=%d' % limit
    extra = ('&cursor=' + cursor) if cursor else ''
    data = json.loads(self.get(url + extra))
    return data['jobs'], data['cursor']

395
  def get(self, request_path, **kwargs):
396 397
    kwargs.setdefault('payload', None)
    return self._send(request_path, **kwargs)
398 399 400 401 402

  def post(self, request_path, data, **kwargs):
    ctype, body = upload.EncodeMultipartFormData(data, [])
    return self._send(request_path, payload=body, content_type=ctype, **kwargs)

403
  def _send(self, request_path, retry_on_404=False, **kwargs):
404
    """Sends a POST/GET to Rietveld.  Returns the response body."""
405 406 407
    # rpc_server.Send() assumes timeout=None by default; make sure it's set
    # to something reasonable.
    kwargs.setdefault('timeout', 15)
408
    logging.debug('POSTing to %s, args %s.', request_path, kwargs)
409 410 411 412 413 414 415 416 417
    try:
      # Sadly, upload.py calls ErrorExit() which does a sys.exit(1) on HTTP
      # 500 in AbstractRpcServer.Send().
      old_error_exit = upload.ErrorExit
      def trap_http_500(msg):
        """Converts an incorrect ErrorExit() call into a HTTPError exception."""
        m = re.search(r'(50\d) Server Error', msg)
        if m:
          # Fake an HTTPError exception. Cheezy. :(
418
          raise urllib2.HTTPError(
419
              request_path, int(m.group(1)), msg, None, StringIO.StringIO())
420 421 422
        old_error_exit(msg)
      upload.ErrorExit = trap_http_500

423
      for retry in xrange(self._maxtries):
424 425
        try:
          logging.debug('%s' % request_path)
426
          return self.rpc_server.Send(request_path, **kwargs)
427
        except urllib2.HTTPError, e:
428
          if retry >= (self._maxtries - 1):
429
            raise
430
          flake_codes = {500, 502, 503}
431
          if retry_on_404:
432
            flake_codes.add(404)
433
          if e.code not in flake_codes:
434 435
            raise
        except urllib2.URLError, e:
436
          if retry >= (self._maxtries - 1):
437
            raise
438 439 440 441 442 443 444 445 446

          def is_transient():
            # The idea here is to retry if the error isn't permanent.
            # Unfortunately, there are so many different possible errors,
            # that we end up enumerating those that are known to us to be
            # transient.
            # The reason can be a string or another exception, e.g.,
            # socket.error or whatever else.
            reason_as_str = str(e.reason)
447
            for retry_anyway in (
448 449
                'Name or service not known',
                'EOF occurred in violation of protocol',
450 451
                'timed out',
                # See http://crbug.com/601260.
452 453
                '[Errno 10060] A connection attempt failed',
                '[Errno 104] Connection reset by peer',
454
            ):
455 456 457 458
              if retry_anyway in reason_as_str:
                return True
            return False  # Assume permanent otherwise.
          if not is_transient():
459 460
            logging.error('Caught urllib2.URLError %s which wasn\'t deemed '
                          'transient', e.reason)
461
            raise
462
        except socket.error, e:
463
          if retry >= (self._maxtries - 1):
464
            raise
465
          if not 'timed out' in str(e):
466
            raise
467
        # If reaching this line, loop again. Uses a small backoff.
468
        time.sleep(min(10, 1+retry*2))
469 470 471
    except urllib2.HTTPError as e:
      print 'Request to %s failed: %s' % (e.geturl(), e.read())
      raise
472 473
    finally:
      upload.ErrorExit = old_error_exit
474 475 476

  # DEPRECATED.
  Send = get
477 478


479 480 481
class OAuthRpcServer(object):
  def __init__(self,
               host,
482
               client_email,
483 484 485 486 487 488 489
               client_private_key,
               private_key_password='notasecret',
               user_agent=None,
               timeout=None,
               extra_headers=None):
    """Wrapper around httplib2.Http() that handles authentication.

490
    client_email: email associated with the service account
491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512
    client_private_key: encrypted private key, as a string
    private_key_password: password used to decrypt the private key
    """

    # Enforce https
    host_parts = urlparse.urlparse(host)

    if host_parts.scheme == 'https':  # fine
      self.host = host
    elif host_parts.scheme == 'http':
      upload.logging.warning('Changing protocol to https')
      self.host = 'https' + host[4:]
    else:
      msg = 'Invalid url provided: %s' % host
      upload.logging.error(msg)
      raise ValueError(msg)

    self.host = self.host.rstrip('/')

    self.extra_headers = extra_headers or {}

    if not oa2client.HAS_OPENSSL:
513
      logging.error("No support for OpenSSL has been found, "
514 515 516
                    "OAuth2 support requires it.")
      logging.error("Installing pyopenssl will probably solve this issue.")
      raise RuntimeError('No OpenSSL support')
517
    self.creds = oa2client.SignedJwtAssertionCredentials(
518
      client_email,
519 520 521 522 523
      client_private_key,
      'https://www.googleapis.com/auth/userinfo.email',
      private_key_password=private_key_password,
      user_agent=user_agent)

524
    self._http = self.creds.authorize(httplib2.Http(timeout=timeout))
525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540

  def Send(self,
           request_path,
           payload=None,
           content_type='application/octet-stream',
           timeout=None,
           extra_headers=None,
           **kwargs):
    """Send a POST or GET request to the server.

    Args:
      request_path: path on the server to hit. This is concatenated with the
        value of 'host' provided to the constructor.
      payload: request is a POST if not None, GET otherwise
      timeout: in seconds
      extra_headers: (dict)
541 542 543 544 545

    Returns: the HTTP response body as a string

    Raises:
      urllib2.HTTPError
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564
    """
    # This method signature should match upload.py:AbstractRpcServer.Send()
    method = 'GET'

    headers = self.extra_headers.copy()
    headers.update(extra_headers or {})

    if payload is not None:
      method = 'POST'
      headers['Content-Type'] = content_type

    prev_timeout = self._http.timeout
    try:
      if timeout:
        self._http.timeout = timeout
      url = self.host + request_path
      if kwargs:
        url += "?" + urllib.urlencode(kwargs)

565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588
      # This weird loop is there to detect when the OAuth2 token has expired.
      # This is specific to appengine *and* rietveld. It relies on the
      # assumption that a 302 is triggered only by an expired OAuth2 token. This
      # prevents any usage of redirections in pages accessed this way.

      # This variable is used to make sure the following loop runs only twice.
      redirect_caught = False
      while True:
        try:
          ret = self._http.request(url,
                                   method=method,
                                   body=payload,
                                   headers=headers,
                                   redirections=0)
        except httplib2.RedirectLimit:
          if redirect_caught or method != 'GET':
            logging.error('Redirection detected after logging in. Giving up.')
            raise
          redirect_caught = True
          logging.debug('Redirection detected. Trying to log in again...')
          self.creds.access_token = None
          continue
        break

589 590 591 592 593
      if ret[0].status >= 300:
        raise urllib2.HTTPError(
            request_path, int(ret[0]['status']), ret[1], None,
            StringIO.StringIO())

594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609
      return ret[1]

    finally:
      self._http.timeout = prev_timeout


class JwtOAuth2Rietveld(Rietveld):
  """Access to Rietveld using OAuth authentication.

  This class is supposed to be used only by bots, since this kind of
  access is restricted to service accounts.
  """
  # The parent__init__ is not called on purpose.
  # pylint: disable=W0231
  def __init__(self,
               url,
610
               client_email,
611 612
               client_private_key_file,
               private_key_password=None,
613 614
               extra_headers=None,
               maxtries=None):
615

616 617 618 619
    if private_key_password is None:  # '' means 'empty password'
      private_key_password = 'notasecret'

    self.url = url.rstrip('/')
620 621 622
    bot_url = self.url
    if self.url.endswith('googleplex.com'):
      bot_url = self.url + '/bots'
623

624 625
    with open(client_private_key_file, 'rb') as f:
      client_private_key = f.read()
626 627 628
    logging.info('Using OAuth login: %s' % client_email)
    self.rpc_server = OAuthRpcServer(bot_url,
                                     client_email,
629 630 631 632 633 634
                                     client_private_key,
                                     private_key_password=private_key_password,
                                     extra_headers=extra_headers or {})
    self._xsrf_token = None
    self._xsrf_token_time = None

635
    self._maxtries = maxtries or 40
636

637

638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685
class CachingRietveld(Rietveld):
  """Caches the common queries.

  Not to be used in long-standing processes, like the commit queue.
  """
  def __init__(self, *args, **kwargs):
    super(CachingRietveld, self).__init__(*args, **kwargs)
    self._cache = {}

  def _lookup(self, function_name, args, update):
    """Caches the return values corresponding to the arguments.

    It is important that the arguments are standardized, like None vs False.
    """
    function_cache = self._cache.setdefault(function_name, {})
    if args not in function_cache:
      function_cache[args] = update(*args)
    return copy.deepcopy(function_cache[args])

  def get_description(self, issue):
    return self._lookup(
        'get_description',
        (issue,),
        super(CachingRietveld, self).get_description)

  def get_issue_properties(self, issue, messages):
    """Returns the issue properties.

    Because in practice the presubmit checks often ask without messages first
    and then with messages, always ask with messages and strip off if not asked
    for the messages.
    """
    # It's a tad slower to request with the message but it's better than
    # requesting the properties twice.
    data = self._lookup(
        'get_issue_properties',
        (issue, True),
        super(CachingRietveld, self).get_issue_properties)
    if not messages:
      # Assumes self._lookup uses deepcopy.
      del data['messages']
    return data

  def get_patchset_properties(self, issue, patchset):
    return self._lookup(
        'get_patchset_properties',
        (issue, patchset),
        super(CachingRietveld, self).get_patchset_properties)
686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732


class ReadOnlyRietveld(object):
  """
  Only provides read operations, and simulates writes locally.

  Intentionally do not inherit from Rietveld to avoid any write-issuing
  logic to be invoked accidentally.
  """

  # Dictionary of local changes, indexed by issue number as int.
  _local_changes = {}

  def __init__(self, *args, **kwargs):
    # We still need an actual Rietveld instance to issue reads, just keep
    # it hidden.
    self._rietveld = Rietveld(*args, **kwargs)

  @classmethod
  def _get_local_changes(cls, issue):
    """Returns dictionary of local changes for |issue|, if any."""
    return cls._local_changes.get(issue, {})

  @property
  def url(self):
    return self._rietveld.url

  def get_pending_issues(self):
    pending_issues = self._rietveld.get_pending_issues()

    # Filter out issues we've closed or unchecked the commit checkbox.
    return [issue for issue in pending_issues
            if not self._get_local_changes(issue).get('closed', False) and
            self._get_local_changes(issue).get('commit', True)]

  def close_issue(self, issue):  # pylint:disable=R0201
    logging.info('ReadOnlyRietveld: closing issue %d' % issue)
    ReadOnlyRietveld._local_changes.setdefault(issue, {})['closed'] = True

  def get_issue_properties(self, issue, messages):
    data = self._rietveld.get_issue_properties(issue, messages)
    data.update(self._get_local_changes(issue))
    return data

  def get_patchset_properties(self, issue, patchset):
    return self._rietveld.get_patchset_properties(issue, patchset)

733 734 735
  def get_depends_on_patchset(self, issue, patchset):
    return self._rietveld.get_depends_on_patchset(issue, patchset)

736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754
  def get_patch(self, issue, patchset):
    return self._rietveld.get_patch(issue, patchset)

  def update_description(self, issue, description):  # pylint:disable=R0201
    logging.info('ReadOnlyRietveld: new description for issue %d: %s' %
        (issue, description))

  def add_comment(self,  # pylint:disable=R0201
                  issue,
                  message,
                  add_as_reviewer=False):
    logging.info('ReadOnlyRietveld: posting comment "%s" to issue %d' %
        (message, issue))

  def set_flag(self, issue, patchset, flag, value):  # pylint:disable=R0201
    logging.info('ReadOnlyRietveld: setting flag "%s" to "%s" for issue %d' %
        (flag, value, issue))
    ReadOnlyRietveld._local_changes.setdefault(issue, {})[flag] = value

755 756 757 758
  def set_flags(self, issue, patchset, flags):
    for flag, value in flags.iteritems():
      self.set_flag(issue, patchset, flag, value)

759
  def trigger_try_jobs(  # pylint:disable=R0201
760
      self, issue, patchset, reason, clobber, revision, builders_and_tests,
761
      master=None, category='cq'):
762 763
    logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
        (builders_and_tests, issue))
764 765

  def trigger_distributed_try_jobs(  # pylint:disable=R0201
766 767
      self, issue, patchset, reason, clobber, revision, masters,
      category='cq'):
768 769
    logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
        (masters, issue))