rietveld.py 26.9 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
    self.post("/%d/close" % issue, [('xsrf_token', self.xsrf_token())])

75
  def get_description(self, issue, force=False):
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
  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,
    }
313 314 315 316 317 318 319
    # The integer values were determined by checking HTML source of Rietveld on
    # https://codereview.chromium.org/search. See also http://crbug.com/712060.
    three_state_value_map = {
        None: 1,   # Unknown.
        True: 2,   # Yes.
        False: 3,  # No.
    }
320 321 322 323 324 325 326 327 328 329

    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:
330
        url += '&%s=%d' % (key, three_state_value_map[value])
331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351

    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']

352
  def trigger_try_jobs(
353
      self, issue, patchset, reason, clobber, revision, builders_and_tests,
354
      master=None, category='cq'):
355 356 357
    """Requests new try jobs.

    |builders_and_tests| is a map of builders: [tests] to run.
358
    |master| is the name of the try master the builders belong to.
359
    |category| is used to distinguish regular jobs and experimental jobs.
360 361 362 363 364 365 366 367

    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()),
368
      ('category', category),
369
    ]
370 371
    if revision:
      params.append(('revision', revision))
372 373 374 375 376
    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))
377 378
    return self.post('/%d/try/%d' % (issue, patchset), params)

379
  def trigger_distributed_try_jobs(
380 381
      self, issue, patchset, reason, clobber, revision, masters,
      category='cq'):
382 383 384
    """Requests new try jobs.

    |masters| is a map of masters: map of builders: [tests] to run.
385
    |category| is used to distinguish regular jobs and experimental jobs.
386 387 388 389
    """
    for (master, builders_and_tests) in masters.iteritems():
      self.trigger_try_jobs(
          issue, patchset, reason, clobber, revision, builders_and_tests,
390
          master, category)
391

392 393 394 395 396 397 398 399 400 401
  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']

402
  def get(self, request_path, **kwargs):
403 404
    kwargs.setdefault('payload', None)
    return self._send(request_path, **kwargs)
405 406 407 408 409

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

410
  def _send(self, request_path, retry_on_404=False, **kwargs):
411
    """Sends a POST/GET to Rietveld.  Returns the response body."""
412 413 414
    # rpc_server.Send() assumes timeout=None by default; make sure it's set
    # to something reasonable.
    kwargs.setdefault('timeout', 15)
415
    logging.debug('POSTing to %s, args %s.', request_path, kwargs)
416 417 418 419 420 421 422 423 424
    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. :(
425
          raise urllib2.HTTPError(
426
              request_path, int(m.group(1)), msg, None, StringIO.StringIO())
427 428 429
        old_error_exit(msg)
      upload.ErrorExit = trap_http_500

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

          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)
454
            for retry_anyway in (
455 456
                'Name or service not known',
                'EOF occurred in violation of protocol',
457 458
                'timed out',
                # See http://crbug.com/601260.
459 460
                '[Errno 10060] A connection attempt failed',
                '[Errno 104] Connection reset by peer',
461
            ):
462 463 464 465
              if retry_anyway in reason_as_str:
                return True
            return False  # Assume permanent otherwise.
          if not is_transient():
466 467
            logging.error('Caught urllib2.URLError %s which wasn\'t deemed '
                          'transient', e.reason)
468
            raise
469
        except socket.error, e:
470
          if retry >= (self._maxtries - 1):
471
            raise
472
          if not 'timed out' in str(e):
473
            raise
474
        # If reaching this line, loop again. Uses a small backoff.
475
        time.sleep(min(10, 1+retry*2))
476 477 478
    except urllib2.HTTPError as e:
      print 'Request to %s failed: %s' % (e.geturl(), e.read())
      raise
479 480
    finally:
      upload.ErrorExit = old_error_exit
481 482 483

  # DEPRECATED.
  Send = get
484 485


486 487 488
class OAuthRpcServer(object):
  def __init__(self,
               host,
489
               client_email,
490 491 492 493 494 495 496
               client_private_key,
               private_key_password='notasecret',
               user_agent=None,
               timeout=None,
               extra_headers=None):
    """Wrapper around httplib2.Http() that handles authentication.

497
    client_email: email associated with the service account
498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519
    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:
520
      logging.error("No support for OpenSSL has been found, "
521 522 523
                    "OAuth2 support requires it.")
      logging.error("Installing pyopenssl will probably solve this issue.")
      raise RuntimeError('No OpenSSL support')
524
    self.creds = oa2client.SignedJwtAssertionCredentials(
525
      client_email,
526 527 528 529 530
      client_private_key,
      'https://www.googleapis.com/auth/userinfo.email',
      private_key_password=private_key_password,
      user_agent=user_agent)

531
    self._http = self.creds.authorize(httplib2.Http(timeout=timeout))
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547

  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)
548 549 550 551 552

    Returns: the HTTP response body as a string

    Raises:
      urllib2.HTTPError
553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571
    """
    # 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)

572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595
      # 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

596 597 598 599 600
      if ret[0].status >= 300:
        raise urllib2.HTTPError(
            request_path, int(ret[0]['status']), ret[1], None,
            StringIO.StringIO())

601 602 603 604 605 606 607 608 609 610 611 612 613
      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.
614
  # pylint: disable=super-init-not-called
615 616
  def __init__(self,
               url,
617
               client_email,
618 619
               client_private_key_file,
               private_key_password=None,
620 621
               extra_headers=None,
               maxtries=None):
622

623 624 625 626
    if private_key_password is None:  # '' means 'empty password'
      private_key_password = 'notasecret'

    self.url = url.rstrip('/')
627 628 629
    bot_url = self.url
    if self.url.endswith('googleplex.com'):
      bot_url = self.url + '/bots'
630

631 632
    with open(client_private_key_file, 'rb') as f:
      client_private_key = f.read()
633 634 635
    logging.info('Using OAuth login: %s' % client_email)
    self.rpc_server = OAuthRpcServer(bot_url,
                                     client_email,
636 637 638 639 640 641
                                     client_private_key,
                                     private_key_password=private_key_password,
                                     extra_headers=extra_headers or {})
    self._xsrf_token = None
    self._xsrf_token_time = None

642
    self._maxtries = maxtries or 40
643

644

645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663
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])

664 665 666 667 668 669 670 671
  def get_description(self, issue, force=False):
    if force:
      return super(CachingRietveld, self).get_description(issue, force=force)
    else:
      return self._lookup(
          'get_description',
          (issue,),
          super(CachingRietveld, self).get_description)
672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695

  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)
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


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)]

731
  def close_issue(self, issue):  # pylint:disable=no-self-use
732 733 734 735 736 737 738 739 740 741 742
    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)

743 744 745
  def get_depends_on_patchset(self, issue, patchset):
    return self._rietveld.get_depends_on_patchset(issue, patchset)

746 747 748
  def get_patch(self, issue, patchset):
    return self._rietveld.get_patch(issue, patchset)

749
  def update_description(self, issue, description):  # pylint:disable=no-self-use
750 751 752
    logging.info('ReadOnlyRietveld: new description for issue %d: %s' %
        (issue, description))

753
  def add_comment(self,  # pylint:disable=no-self-use
754 755 756 757 758 759
                  issue,
                  message,
                  add_as_reviewer=False):
    logging.info('ReadOnlyRietveld: posting comment "%s" to issue %d' %
        (message, issue))

760
  def set_flag(self, issue, patchset, flag, value):  # pylint:disable=no-self-use
761 762 763 764
    logging.info('ReadOnlyRietveld: setting flag "%s" to "%s" for issue %d' %
        (flag, value, issue))
    ReadOnlyRietveld._local_changes.setdefault(issue, {})[flag] = value

765 766 767 768
  def set_flags(self, issue, patchset, flags):
    for flag, value in flags.iteritems():
      self.set_flag(issue, patchset, flag, value)

769
  def trigger_try_jobs(  # pylint:disable=no-self-use
770
      self, issue, patchset, reason, clobber, revision, builders_and_tests,
771
      master=None, category='cq'):
772 773
    logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
        (builders_and_tests, issue))
774

775
  def trigger_distributed_try_jobs(  # pylint:disable=no-self-use
776 777
      self, issue, patchset, reason, clobber, revision, masters,
      category='cq'):
778 779
    logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
        (masters, issue))