[email protected] | 2c96af7 | 2012-05-04 13:19:03 | [diff] [blame] | 1 | # coding: utf-8 |
[email protected] | 9799a07 | 2012-01-11 00:26:25 | [diff] [blame] | 2 | # Copyright (c) 2012 The Chromium Authors. All rights reserved. |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | """Defines class Rietveld to easily access a rietveld instance. |
| 6 | |
| 7 | Security implications: |
| 8 | |
| 9 | The following hypothesis are made: |
| 10 | - Rietveld enforces: |
| 11 | - Nobody else than issue owner can upload a patch set |
| 12 | - Verifies the issue owner credentials when creating new issues |
| 13 | - A issue owner can't change once the issue is created |
| 14 | - A patch set cannot be modified |
| 15 | """ |
| 16 | |
[email protected] | 4bac4b5 | 2012-11-27 20:33:52 | [diff] [blame] | 17 | import copy |
[email protected] | c15d2a0 | 2015-10-02 19:41:39 | [diff] [blame] | 18 | import errno |
[email protected] | 4f6852c | 2012-04-20 20:39:20 | [diff] [blame] | 19 | import json |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 20 | import logging |
[email protected] | 0315241 | 2011-09-01 14:42:49 | [diff] [blame] | 21 | import re |
[email protected] | c15d2a0 | 2015-10-02 19:41:39 | [diff] [blame] | 22 | import socket |
[email protected] | 36bc384 | 2014-02-25 22:36:13 | [diff] [blame] | 23 | import ssl |
[email protected] | 1dda36d | 2016-02-11 00:28:39 | [diff] [blame] | 24 | import StringIO |
[email protected] | c15d2a0 | 2015-10-02 19:41:39 | [diff] [blame] | 25 | import sys |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 26 | import time |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 27 | import urllib |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 28 | import urllib2 |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 29 | import urlparse |
| 30 | |
| 31 | import patch |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 32 | |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 33 | from third_party import upload |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 34 | import third_party.oauth2client.client as oa2client |
| 35 | from third_party import httplib2 |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 36 | |
[email protected] | e7f4e02 | 2014-04-17 21:19:36 | [diff] [blame] | 37 | # Appengine replies with 302 when authentication fails (sigh.) |
| 38 | oa2client.REFRESH_STATUS_CODES.append(302) |
[email protected] | 333087e | 2014-04-09 20:19:29 | [diff] [blame] | 39 | upload.LOGGER.setLevel(logging.WARNING) # pylint: disable=E1103 |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 40 | |
| 41 | |
| 42 | class Rietveld(object): |
| 43 | """Accesses rietveld.""" |
[email protected] | cf6a5d2 | 2015-04-09 22:02:00 | [diff] [blame] | 44 | def __init__( |
| 45 | self, url, auth_config, email=None, extra_headers=None, maxtries=None): |
[email protected] | 3e9e432 | 2011-09-28 00:11:31 | [diff] [blame] | 46 | self.url = url.rstrip('/') |
[email protected] | cf6a5d2 | 2015-04-09 22:02:00 | [diff] [blame] | 47 | self.rpc_server = upload.GetRpcServer(self.url, auth_config, email) |
[email protected] | ed23325 | 2012-07-06 17:25:11 | [diff] [blame] | 48 | |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 49 | self._xsrf_token = None |
| 50 | self._xsrf_token_time = None |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 51 | |
[email protected] | ab8154f | 2015-02-19 11:29:00 | [diff] [blame] | 52 | self._maxtries = maxtries or 40 |
| 53 | |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 54 | def xsrf_token(self): |
| 55 | if (not self._xsrf_token_time or |
| 56 | (time.time() - self._xsrf_token_time) > 30*60): |
| 57 | self._xsrf_token_time = time.time() |
| 58 | self._xsrf_token = self.get( |
| 59 | '/xsrf_token', |
| 60 | extra_headers={'X-Requesting-XSRF-Token': '1'}) |
| 61 | return self._xsrf_token |
| 62 | |
| 63 | def get_pending_issues(self): |
| 64 | """Returns an array of dict of all the pending issues on the server.""" |
[email protected] | b812828 | 2012-11-09 00:45:48 | [diff] [blame] | 65 | # TODO: Convert this to use Rietveld::search(), defined below. |
| 66 | return json.loads( |
| 67 | self.get('/search?format=json&commit=2&closed=3&' |
| 68 | 'keys_only=True&limit=1000&order=__key__'))['results'] |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 69 | |
| 70 | def close_issue(self, issue): |
| 71 | """Closes the Rietveld issue for this changelist.""" |
[email protected] | 8f4e5bf | 2012-08-22 12:00:36 | [diff] [blame] | 72 | logging.info('closing issue %d' % issue) |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 73 | self.post("/%d/close" % issue, [('xsrf_token', self.xsrf_token())]) |
| 74 | |
Kenneth Russell | 61e2ed4 | 2017-02-15 19:47:13 | [diff] [blame] | 75 | def get_description(self, issue, force=False): |
[email protected] | 4572a09 | 2013-05-09 21:30:46 | [diff] [blame] | 76 | """Returns the issue's description. |
| 77 | |
| 78 | Converts any CRLF into LF and strip extraneous whitespace. |
| 79 | """ |
| 80 | return '\n'.join(self.get('/%d/description' % issue).strip().splitlines()) |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 81 | |
| 82 | def get_issue_properties(self, issue, messages): |
| 83 | """Returns all the issue's metadata as a dictionary.""" |
[email protected] | 8f4e5bf | 2012-08-22 12:00:36 | [diff] [blame] | 84 | url = '/api/%d' % issue |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 85 | if messages: |
| 86 | url += '?messages=true' |
[email protected] | d612e49 | 2014-08-27 14:00:41 | [diff] [blame] | 87 | data = json.loads(self.get(url, retry_on_404=True)) |
[email protected] | 4572a09 | 2013-05-09 21:30:46 | [diff] [blame] | 88 | data['description'] = '\n'.join(data['description'].strip().splitlines()) |
| 89 | return data |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 90 | |
[email protected] | d91b7e3 | 2015-06-23 11:24:07 | [diff] [blame] | 91 | def get_depends_on_patchset(self, issue, patchset): |
| 92 | """Returns the patchset this patchset depends on if it exists.""" |
| 93 | url = '/%d/patchset/%d/get_depends_on_patchset' % (issue, patchset) |
| 94 | resp = None |
| 95 | try: |
[email protected] | 816d085 | 2015-08-25 16:57:02 | [diff] [blame] | 96 | resp = json.loads(self.post(url, [])) |
[email protected] | d91b7e3 | 2015-06-23 11:24:07 | [diff] [blame] | 97 | except (urllib2.HTTPError, ValueError): |
| 98 | # The get_depends_on_patchset endpoint does not exist on this Rietveld |
| 99 | # instance yet. Ignore the error and proceed. |
| 100 | # TODO(rmistry): Make this an error when all Rietveld instances have |
| 101 | # this endpoint. |
| 102 | pass |
| 103 | return resp |
| 104 | |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 105 | def get_patchset_properties(self, issue, patchset): |
| 106 | """Returns the patchset properties.""" |
[email protected] | 8f4e5bf | 2012-08-22 12:00:36 | [diff] [blame] | 107 | url = '/api/%d/%d' % (issue, patchset) |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 108 | return json.loads(self.get(url)) |
| 109 | |
| 110 | def get_file_content(self, issue, patchset, item): |
| 111 | """Returns the content of a new file. |
| 112 | |
| 113 | Throws HTTP 302 exception if the file doesn't exist or is not a binary file. |
| 114 | """ |
| 115 | # content = 0 is the old file, 1 is the new file. |
| 116 | content = 1 |
[email protected] | 8f4e5bf | 2012-08-22 12:00:36 | [diff] [blame] | 117 | url = '/%d/binary/%d/%d/%d' % (issue, patchset, item, content) |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 118 | return self.get(url) |
| 119 | |
| 120 | def get_file_diff(self, issue, patchset, item): |
| 121 | """Returns the diff of the file. |
| 122 | |
| 123 | Returns a useless diff for binary files. |
| 124 | """ |
[email protected] | 8f4e5bf | 2012-08-22 12:00:36 | [diff] [blame] | 125 | url = '/download/issue%d_%d_%d.diff' % (issue, patchset, item) |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 126 | return self.get(url) |
| 127 | |
| 128 | def get_patch(self, issue, patchset): |
| 129 | """Returns a PatchSet object containing the details to apply this patch.""" |
| 130 | props = self.get_patchset_properties(issue, patchset) or {} |
| 131 | out = [] |
| 132 | for filename, state in props.get('files', {}).iteritems(): |
[email protected] | 61e0b69 | 2011-04-12 21:01:01 | [diff] [blame] | 133 | logging.debug('%s' % filename) |
[email protected] | 264952a | 2012-05-01 18:32:47 | [diff] [blame] | 134 | # If not status, just assume it's a 'M'. Rietveld often gets it wrong and |
| 135 | # just has status: null. Oh well. |
| 136 | status = state.get('status') or 'M' |
[email protected] | 40f4ad3 | 2012-05-08 21:29:24 | [diff] [blame] | 137 | if status[0] not in ('A', 'D', 'M', 'R'): |
[email protected] | 087066c | 2011-09-08 20:35:13 | [diff] [blame] | 138 | raise patch.UnsupportedPatchFormat( |
| 139 | filename, 'Change with status \'%s\' is not supported.' % status) |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 140 | |
[email protected] | 087066c | 2011-09-08 20:35:13 | [diff] [blame] | 141 | svn_props = self.parse_svn_properties( |
| 142 | state.get('property_changes', ''), filename) |
| 143 | |
| 144 | if state.get('is_binary'): |
| 145 | if status[0] == 'D': |
| 146 | if status[0] != status.strip(): |
| 147 | raise patch.UnsupportedPatchFormat( |
| 148 | filename, 'Deleted file shouldn\'t have property change.') |
| 149 | out.append(patch.FilePatchDelete(filename, state['is_binary'])) |
| 150 | else: |
[email protected] | de85d9c | 2012-10-03 19:10:40 | [diff] [blame] | 151 | content = self.get_file_content(issue, patchset, state['id']) |
[email protected] | 03d762f | 2016-01-22 21:13:23 | [diff] [blame] | 152 | if not content or content == 'None': |
[email protected] | de85d9c | 2012-10-03 19:10:40 | [diff] [blame] | 153 | # As a precaution due to a bug in upload.py for git checkout, refuse |
| 154 | # empty files. If it's empty, it's not a binary file. |
| 155 | raise patch.UnsupportedPatchFormat( |
| 156 | filename, |
| 157 | 'Binary file is empty. Maybe the file wasn\'t uploaded in the ' |
| 158 | 'first place?') |
[email protected] | 8bbdb7e | 2013-09-11 00:49:17 | [diff] [blame] | 159 | out.append(patch.FilePatchBinary( |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 160 | filename, |
[email protected] | 8bbdb7e | 2013-09-11 00:49:17 | [diff] [blame] | 161 | content, |
| 162 | svn_props, |
| 163 | is_new=(status[0] == 'A'))) |
[email protected] | 087066c | 2011-09-08 20:35:13 | [diff] [blame] | 164 | continue |
| 165 | |
| 166 | try: |
| 167 | diff = self.get_file_diff(issue, patchset, state['id']) |
| 168 | except urllib2.HTTPError, e: |
| 169 | if e.code == 404: |
| 170 | raise patch.UnsupportedPatchFormat( |
| 171 | filename, 'File doesn\'t have a diff.') |
| 172 | raise |
| 173 | |
| 174 | # FilePatchDiff() will detect file deletion automatically. |
| 175 | p = patch.FilePatchDiff(filename, diff, svn_props) |
| 176 | out.append(p) |
| 177 | if status[0] == 'A': |
| 178 | # It won't be set for empty file. |
| 179 | p.is_new = True |
| 180 | if (len(status) > 1 and |
| 181 | status[1] == '+' and |
| 182 | not (p.source_filename or p.svn_properties)): |
[email protected] | 6cdac9e | 2011-09-07 14:25:40 | [diff] [blame] | 183 | raise patch.UnsupportedPatchFormat( |
[email protected] | 087066c | 2011-09-08 20:35:13 | [diff] [blame] | 184 | filename, 'Failed to process the svn properties') |
[email protected] | 6cdac9e | 2011-09-07 14:25:40 | [diff] [blame] | 185 | |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 186 | return patch.PatchSet(out) |
| 187 | |
[email protected] | 0315241 | 2011-09-01 14:42:49 | [diff] [blame] | 188 | @staticmethod |
| 189 | def parse_svn_properties(rietveld_svn_props, filename): |
| 190 | """Returns a list of tuple [('property', 'newvalue')]. |
| 191 | |
| 192 | rietveld_svn_props is the exact format from 'svn diff'. |
| 193 | """ |
| 194 | rietveld_svn_props = rietveld_svn_props.splitlines() |
| 195 | svn_props = [] |
| 196 | if not rietveld_svn_props: |
| 197 | return svn_props |
| 198 | # 1. Ignore svn:mergeinfo. |
| 199 | # 2. Accept svn:eol-style and svn:executable. |
| 200 | # 3. Refuse any other. |
| 201 | # \n |
| 202 | # Added: svn:ignore\n |
| 203 | # + LF\n |
[email protected] | 0315241 | 2011-09-01 14:42:49 | [diff] [blame] | 204 | |
[email protected] | e2335ae | 2011-09-08 18:52:33 | [diff] [blame] | 205 | spacer = rietveld_svn_props.pop(0) |
| 206 | if spacer or not rietveld_svn_props: |
| 207 | # svn diff always put a spacer between the unified diff and property |
| 208 | # diff |
| 209 | raise patch.UnsupportedPatchFormat( |
| 210 | filename, 'Failed to parse svn properties.') |
| 211 | |
| 212 | while rietveld_svn_props: |
[email protected] | 0315241 | 2011-09-01 14:42:49 | [diff] [blame] | 213 | # Something like 'Added: svn:eol-style'. Note the action is localized. |
| 214 | # *sigh*. |
| 215 | action = rietveld_svn_props.pop(0) |
| 216 | match = re.match(r'^(\w+): (.+)$', action) |
| 217 | if not match or not rietveld_svn_props: |
| 218 | raise patch.UnsupportedPatchFormat( |
[email protected] | 9799a07 | 2012-01-11 00:26:25 | [diff] [blame] | 219 | filename, |
| 220 | 'Failed to parse svn properties: %s, %s' % (action, svn_props)) |
[email protected] | 0315241 | 2011-09-01 14:42:49 | [diff] [blame] | 221 | |
| 222 | if match.group(2) == 'svn:mergeinfo': |
| 223 | # Silently ignore the content. |
| 224 | rietveld_svn_props.pop(0) |
| 225 | continue |
| 226 | |
| 227 | if match.group(1) not in ('Added', 'Modified'): |
| 228 | # Will fail for our French friends. |
| 229 | raise patch.UnsupportedPatchFormat( |
| 230 | filename, 'Unsupported svn property operation.') |
| 231 | |
[email protected] | 9799a07 | 2012-01-11 00:26:25 | [diff] [blame] | 232 | if match.group(2) in ('svn:eol-style', 'svn:executable', 'svn:mime-type'): |
[email protected] | 0315241 | 2011-09-01 14:42:49 | [diff] [blame] | 233 | # ' + foo' where foo is the new value. That's fragile. |
| 234 | content = rietveld_svn_props.pop(0) |
| 235 | match2 = re.match(r'^ \+ (.*)$', content) |
| 236 | if not match2: |
| 237 | raise patch.UnsupportedPatchFormat( |
| 238 | filename, 'Unsupported svn property format.') |
| 239 | svn_props.append((match.group(2), match2.group(1))) |
| 240 | return svn_props |
| 241 | |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 242 | def update_description(self, issue, description): |
| 243 | """Sets the description for an issue on Rietveld.""" |
[email protected] | 8f4e5bf | 2012-08-22 12:00:36 | [diff] [blame] | 244 | logging.info('new description for issue %d' % issue) |
| 245 | self.post('/%d/description' % issue, [ |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 246 | ('description', description), |
| 247 | ('xsrf_token', self.xsrf_token())]) |
| 248 | |
[email protected] | 8939820 | 2012-09-06 07:37:10 | [diff] [blame] | 249 | def add_comment(self, issue, message, add_as_reviewer=False): |
[email protected] | 2c96af7 | 2012-05-04 13:19:03 | [diff] [blame] | 250 | max_message = 10000 |
| 251 | tail = '…\n(message too large)' |
| 252 | if len(message) > max_message: |
| 253 | message = message[:max_message-len(tail)] + tail |
[email protected] | 0df1e0d | 2013-01-18 18:21:33 | [diff] [blame] | 254 | logging.info('issue %d; comment: %s' % (issue, message.strip()[:300])) |
[email protected] | 8f4e5bf | 2012-08-22 12:00:36 | [diff] [blame] | 255 | return self.post('/%d/publish' % issue, [ |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 256 | ('xsrf_token', self.xsrf_token()), |
| 257 | ('message', message), |
| 258 | ('message_only', 'True'), |
[email protected] | 8939820 | 2012-09-06 07:37:10 | [diff] [blame] | 259 | ('add_as_reviewer', str(bool(add_as_reviewer))), |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 260 | ('send_mail', 'True'), |
| 261 | ('no_redirect', 'True')]) |
| 262 | |
[email protected] | ac98e29 | 2014-01-13 17:48:49 | [diff] [blame] | 263 | def add_inline_comment( |
| 264 | self, issue, text, side, snapshot, patchset, patchid, lineno): |
| 265 | logging.info('add inline comment for issue %d' % issue) |
| 266 | return self.post('/inline_draft', [ |
| 267 | ('issue', str(issue)), |
| 268 | ('text', text), |
| 269 | ('side', side), |
| 270 | ('snapshot', snapshot), |
| 271 | ('patchset', str(patchset)), |
| 272 | ('patch', str(patchid)), |
| 273 | ('lineno', str(lineno))]) |
| 274 | |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 275 | def set_flag(self, issue, patchset, flag, value): |
[email protected] | 8f4e5bf | 2012-08-22 12:00:36 | [diff] [blame] | 276 | return self.post('/%d/edit_flags' % issue, [ |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 277 | ('last_patchset', str(patchset)), |
| 278 | ('xsrf_token', self.xsrf_token()), |
[email protected] | de9c675 | 2013-09-27 19:07:47 | [diff] [blame] | 279 | (flag, str(value))]) |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 280 | |
tandrii | 4b233bd | 2016-07-06 10:50:29 | [diff] [blame] | 281 | def set_flags(self, issue, patchset, flags): |
| 282 | return self.post('/%d/edit_flags' % issue, [ |
| 283 | ('last_patchset', str(patchset)), |
| 284 | ('xsrf_token', self.xsrf_token()), |
| 285 | ] + [(flag, str(value)) for flag, value in flags.iteritems()]) |
| 286 | |
[email protected] | c73e516 | 2011-09-21 23:16:12 | [diff] [blame] | 287 | def search( |
| 288 | self, |
| 289 | owner=None, reviewer=None, |
| 290 | base=None, |
| 291 | closed=None, private=None, commit=None, |
| 292 | created_before=None, created_after=None, |
| 293 | modified_before=None, modified_after=None, |
| 294 | per_request=None, keys_only=False, |
| 295 | with_messages=False): |
| 296 | """Yields search results.""" |
| 297 | # These are expected to be strings. |
| 298 | string_keys = { |
| 299 | 'owner': owner, |
| 300 | 'reviewer': reviewer, |
| 301 | 'base': base, |
| 302 | 'created_before': created_before, |
| 303 | 'created_after': created_after, |
| 304 | 'modified_before': modified_before, |
| 305 | 'modified_after': modified_after, |
| 306 | } |
| 307 | # These are either None, False or True. |
| 308 | three_state_keys = { |
| 309 | 'closed': closed, |
| 310 | 'private': private, |
| 311 | 'commit': commit, |
| 312 | } |
Andrii Shyshkalov | e6e2375 | 2017-04-19 22:14:03 | [diff] [blame] | 313 | # The integer values were determined by checking HTML source of Rietveld on |
| 314 | # https://codereview.chromium.org/search. See also http://crbug.com/712060. |
| 315 | three_state_value_map = { |
| 316 | None: 1, # Unknown. |
| 317 | True: 2, # Yes. |
| 318 | False: 3, # No. |
| 319 | } |
[email protected] | c73e516 | 2011-09-21 23:16:12 | [diff] [blame] | 320 | |
| 321 | url = '/search?format=json' |
| 322 | # Sort the keys mainly to ease testing. |
| 323 | for key in sorted(string_keys): |
| 324 | value = string_keys[key] |
| 325 | if value: |
| 326 | url += '&%s=%s' % (key, urllib2.quote(value)) |
| 327 | for key in sorted(three_state_keys): |
| 328 | value = three_state_keys[key] |
| 329 | if value is not None: |
Andrii Shyshkalov | e6e2375 | 2017-04-19 22:14:03 | [diff] [blame] | 330 | url += '&%s=%d' % (key, three_state_value_map[value]) |
[email protected] | c73e516 | 2011-09-21 23:16:12 | [diff] [blame] | 331 | |
| 332 | if keys_only: |
| 333 | url += '&keys_only=True' |
| 334 | if with_messages: |
| 335 | url += '&with_messages=True' |
| 336 | if per_request: |
| 337 | url += '&limit=%d' % per_request |
| 338 | |
| 339 | cursor = '' |
| 340 | while True: |
| 341 | output = self.get(url + cursor) |
| 342 | if output.startswith('<'): |
| 343 | # It's an error message. Return as no result. |
| 344 | break |
| 345 | data = json.loads(output) or {} |
| 346 | if not data.get('results'): |
| 347 | break |
| 348 | for i in data['results']: |
| 349 | yield i |
| 350 | cursor = '&cursor=%s' % data['cursor'] |
| 351 | |
[email protected] | 61ea42f | 2012-09-05 14:58:52 | [diff] [blame] | 352 | def trigger_try_jobs( |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 353 | self, issue, patchset, reason, clobber, revision, builders_and_tests, |
[email protected] | 62554f9 | 2015-01-06 00:42:39 | [diff] [blame] | 354 | master=None, category='cq'): |
[email protected] | 61ea42f | 2012-09-05 14:58:52 | [diff] [blame] | 355 | """Requests new try jobs. |
| 356 | |
| 357 | |builders_and_tests| is a map of builders: [tests] to run. |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 358 | |master| is the name of the try master the builders belong to. |
[email protected] | 62554f9 | 2015-01-06 00:42:39 | [diff] [blame] | 359 | |category| is used to distinguish regular jobs and experimental jobs. |
[email protected] | 61ea42f | 2012-09-05 14:58:52 | [diff] [blame] | 360 | |
| 361 | Returns the keys of the new TryJobResult entites. |
| 362 | """ |
| 363 | params = [ |
| 364 | ('reason', reason), |
| 365 | ('clobber', 'True' if clobber else 'False'), |
[email protected] | 61ea42f | 2012-09-05 14:58:52 | [diff] [blame] | 366 | ('builders', json.dumps(builders_and_tests)), |
| 367 | ('xsrf_token', self.xsrf_token()), |
[email protected] | 62554f9 | 2015-01-06 00:42:39 | [diff] [blame] | 368 | ('category', category), |
[email protected] | 61ea42f | 2012-09-05 14:58:52 | [diff] [blame] | 369 | ] |
[email protected] | 072d94b | 2012-09-20 19:20:08 | [diff] [blame] | 370 | if revision: |
| 371 | params.append(('revision', revision)) |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 372 | if master: |
| 373 | # Temporarily allow empty master names for old configurations. The try |
| 374 | # job will not be associated with a master name on rietveld. This is |
| 375 | # going to be deprecated. |
| 376 | params.append(('master', master)) |
[email protected] | 61ea42f | 2012-09-05 14:58:52 | [diff] [blame] | 377 | return self.post('/%d/try/%d' % (issue, patchset), params) |
| 378 | |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 379 | def trigger_distributed_try_jobs( |
[email protected] | 62554f9 | 2015-01-06 00:42:39 | [diff] [blame] | 380 | self, issue, patchset, reason, clobber, revision, masters, |
| 381 | category='cq'): |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 382 | """Requests new try jobs. |
| 383 | |
| 384 | |masters| is a map of masters: map of builders: [tests] to run. |
[email protected] | 62554f9 | 2015-01-06 00:42:39 | [diff] [blame] | 385 | |category| is used to distinguish regular jobs and experimental jobs. |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 386 | """ |
| 387 | for (master, builders_and_tests) in masters.iteritems(): |
| 388 | self.trigger_try_jobs( |
| 389 | issue, patchset, reason, clobber, revision, builders_and_tests, |
[email protected] | 62554f9 | 2015-01-06 00:42:39 | [diff] [blame] | 390 | master, category) |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 391 | |
[email protected] | 61ea42f | 2012-09-05 14:58:52 | [diff] [blame] | 392 | def get_pending_try_jobs(self, cursor=None, limit=100): |
| 393 | """Retrieves the try job requests in pending state. |
| 394 | |
| 395 | Returns a tuple of the list of try jobs and the cursor for the next request. |
| 396 | """ |
| 397 | url = '/get_pending_try_patchsets?limit=%d' % limit |
| 398 | extra = ('&cursor=' + cursor) if cursor else '' |
| 399 | data = json.loads(self.get(url + extra)) |
| 400 | return data['jobs'], data['cursor'] |
| 401 | |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 402 | def get(self, request_path, **kwargs): |
[email protected] | cab38e9 | 2011-04-09 00:30:51 | [diff] [blame] | 403 | kwargs.setdefault('payload', None) |
| 404 | return self._send(request_path, **kwargs) |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 405 | |
| 406 | def post(self, request_path, data, **kwargs): |
| 407 | ctype, body = upload.EncodeMultipartFormData(data, []) |
| 408 | return self._send(request_path, payload=body, content_type=ctype, **kwargs) |
| 409 | |
[email protected] | d612e49 | 2014-08-27 14:00:41 | [diff] [blame] | 410 | def _send(self, request_path, retry_on_404=False, **kwargs): |
[email protected] | b3727a3 | 2011-04-04 19:31:44 | [diff] [blame] | 411 | """Sends a POST/GET to Rietveld. Returns the response body.""" |
[email protected] | e0faffa | 2014-02-21 01:55:31 | [diff] [blame] | 412 | # rpc_server.Send() assumes timeout=None by default; make sure it's set |
| 413 | # to something reasonable. |
| 414 | kwargs.setdefault('timeout', 15) |
[email protected] | eebd3c9 | 2013-01-07 18:34:49 | [diff] [blame] | 415 | logging.debug('POSTing to %s, args %s.', request_path, kwargs) |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 416 | try: |
| 417 | # Sadly, upload.py calls ErrorExit() which does a sys.exit(1) on HTTP |
| 418 | # 500 in AbstractRpcServer.Send(). |
| 419 | old_error_exit = upload.ErrorExit |
| 420 | def trap_http_500(msg): |
| 421 | """Converts an incorrect ErrorExit() call into a HTTPError exception.""" |
| 422 | m = re.search(r'(50\d) Server Error', msg) |
| 423 | if m: |
| 424 | # Fake an HTTPError exception. Cheezy. :( |
[email protected] | cef7236 | 2013-10-15 22:52:59 | [diff] [blame] | 425 | raise urllib2.HTTPError( |
[email protected] | 1dda36d | 2016-02-11 00:28:39 | [diff] [blame] | 426 | request_path, int(m.group(1)), msg, None, StringIO.StringIO()) |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 427 | old_error_exit(msg) |
| 428 | upload.ErrorExit = trap_http_500 |
| 429 | |
[email protected] | ab8154f | 2015-02-19 11:29:00 | [diff] [blame] | 430 | for retry in xrange(self._maxtries): |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 431 | try: |
| 432 | logging.debug('%s' % request_path) |
[email protected] | 1dda36d | 2016-02-11 00:28:39 | [diff] [blame] | 433 | return self.rpc_server.Send(request_path, **kwargs) |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 434 | except urllib2.HTTPError, e: |
[email protected] | ab8154f | 2015-02-19 11:29:00 | [diff] [blame] | 435 | if retry >= (self._maxtries - 1): |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 436 | raise |
[email protected] | 1dda36d | 2016-02-11 00:28:39 | [diff] [blame] | 437 | flake_codes = {500, 502, 503} |
[email protected] | d612e49 | 2014-08-27 14:00:41 | [diff] [blame] | 438 | if retry_on_404: |
[email protected] | 1dda36d | 2016-02-11 00:28:39 | [diff] [blame] | 439 | flake_codes.add(404) |
[email protected] | d612e49 | 2014-08-27 14:00:41 | [diff] [blame] | 440 | if e.code not in flake_codes: |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 441 | raise |
| 442 | except urllib2.URLError, e: |
[email protected] | ab8154f | 2015-02-19 11:29:00 | [diff] [blame] | 443 | if retry >= (self._maxtries - 1): |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 444 | raise |
[email protected] | 29d5e56 | 2015-12-03 15:00:34 | [diff] [blame] | 445 | |
| 446 | def is_transient(): |
| 447 | # The idea here is to retry if the error isn't permanent. |
| 448 | # Unfortunately, there are so many different possible errors, |
| 449 | # that we end up enumerating those that are known to us to be |
| 450 | # transient. |
| 451 | # The reason can be a string or another exception, e.g., |
| 452 | # socket.error or whatever else. |
| 453 | reason_as_str = str(e.reason) |
[email protected] | 1dda36d | 2016-02-11 00:28:39 | [diff] [blame] | 454 | for retry_anyway in ( |
[email protected] | 29d5e56 | 2015-12-03 15:00:34 | [diff] [blame] | 455 | 'Name or service not known', |
| 456 | 'EOF occurred in violation of protocol', |
[email protected] | 432fb94 | 2016-04-11 17:29:46 | [diff] [blame] | 457 | 'timed out', |
| 458 | # See http://crbug.com/601260. |
Andrii Shyshkalov | 1bf69a1 | 2016-11-29 17:26:23 | [diff] [blame] | 459 | '[Errno 10060] A connection attempt failed', |
| 460 | '[Errno 104] Connection reset by peer', |
[email protected] | 432fb94 | 2016-04-11 17:29:46 | [diff] [blame] | 461 | ): |
[email protected] | 29d5e56 | 2015-12-03 15:00:34 | [diff] [blame] | 462 | if retry_anyway in reason_as_str: |
| 463 | return True |
| 464 | return False # Assume permanent otherwise. |
| 465 | if not is_transient(): |
Andrii Shyshkalov | 1bf69a1 | 2016-11-29 17:26:23 | [diff] [blame] | 466 | logging.error('Caught urllib2.URLError %s which wasn\'t deemed ' |
| 467 | 'transient', e.reason) |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 468 | raise |
[email protected] | f4ef3e7 | 2015-10-22 00:39:32 | [diff] [blame] | 469 | except socket.error, e: |
[email protected] | ab8154f | 2015-02-19 11:29:00 | [diff] [blame] | 470 | if retry >= (self._maxtries - 1): |
[email protected] | 36bc384 | 2014-02-25 22:36:13 | [diff] [blame] | 471 | raise |
[email protected] | 04bd6b1 | 2014-02-26 01:04:04 | [diff] [blame] | 472 | if not 'timed out' in str(e): |
[email protected] | 36bc384 | 2014-02-25 22:36:13 | [diff] [blame] | 473 | raise |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 474 | # If reaching this line, loop again. Uses a small backoff. |
[email protected] | 538f602 | 2014-06-13 17:08:01 | [diff] [blame] | 475 | time.sleep(min(10, 1+retry*2)) |
[email protected] | e4d195a | 2015-10-05 15:45:18 | [diff] [blame] | 476 | except urllib2.HTTPError as e: |
| 477 | print 'Request to %s failed: %s' % (e.geturl(), e.read()) |
| 478 | raise |
[email protected] | a01fd32 | 2012-02-27 19:11:08 | [diff] [blame] | 479 | finally: |
| 480 | upload.ErrorExit = old_error_exit |
[email protected] | cab38e9 | 2011-04-09 00:30:51 | [diff] [blame] | 481 | |
| 482 | # DEPRECATED. |
| 483 | Send = get |
[email protected] | 4bac4b5 | 2012-11-27 20:33:52 | [diff] [blame] | 484 | |
| 485 | |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 486 | class OAuthRpcServer(object): |
| 487 | def __init__(self, |
| 488 | host, |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 489 | client_email, |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 490 | client_private_key, |
| 491 | private_key_password='notasecret', |
| 492 | user_agent=None, |
| 493 | timeout=None, |
| 494 | extra_headers=None): |
| 495 | """Wrapper around httplib2.Http() that handles authentication. |
| 496 | |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 497 | client_email: email associated with the service account |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 498 | client_private_key: encrypted private key, as a string |
| 499 | private_key_password: password used to decrypt the private key |
| 500 | """ |
| 501 | |
| 502 | # Enforce https |
| 503 | host_parts = urlparse.urlparse(host) |
| 504 | |
| 505 | if host_parts.scheme == 'https': # fine |
| 506 | self.host = host |
| 507 | elif host_parts.scheme == 'http': |
| 508 | upload.logging.warning('Changing protocol to https') |
| 509 | self.host = 'https' + host[4:] |
| 510 | else: |
| 511 | msg = 'Invalid url provided: %s' % host |
| 512 | upload.logging.error(msg) |
| 513 | raise ValueError(msg) |
| 514 | |
| 515 | self.host = self.host.rstrip('/') |
| 516 | |
| 517 | self.extra_headers = extra_headers or {} |
| 518 | |
| 519 | if not oa2client.HAS_OPENSSL: |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 520 | logging.error("No support for OpenSSL has been found, " |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 521 | "OAuth2 support requires it.") |
| 522 | logging.error("Installing pyopenssl will probably solve this issue.") |
| 523 | raise RuntimeError('No OpenSSL support') |
[email protected] | 3fd55f9 | 2014-04-18 22:17:21 | [diff] [blame] | 524 | self.creds = oa2client.SignedJwtAssertionCredentials( |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 525 | client_email, |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 526 | client_private_key, |
| 527 | 'https://www.googleapis.com/auth/userinfo.email', |
| 528 | private_key_password=private_key_password, |
| 529 | user_agent=user_agent) |
| 530 | |
[email protected] | 3fd55f9 | 2014-04-18 22:17:21 | [diff] [blame] | 531 | self._http = self.creds.authorize(httplib2.Http(timeout=timeout)) |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 532 | |
| 533 | def Send(self, |
| 534 | request_path, |
| 535 | payload=None, |
| 536 | content_type='application/octet-stream', |
| 537 | timeout=None, |
| 538 | extra_headers=None, |
| 539 | **kwargs): |
| 540 | """Send a POST or GET request to the server. |
| 541 | |
| 542 | Args: |
| 543 | request_path: path on the server to hit. This is concatenated with the |
| 544 | value of 'host' provided to the constructor. |
| 545 | payload: request is a POST if not None, GET otherwise |
| 546 | timeout: in seconds |
| 547 | extra_headers: (dict) |
[email protected] | 1dda36d | 2016-02-11 00:28:39 | [diff] [blame] | 548 | |
| 549 | Returns: the HTTP response body as a string |
| 550 | |
| 551 | Raises: |
| 552 | urllib2.HTTPError |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 553 | """ |
| 554 | # This method signature should match upload.py:AbstractRpcServer.Send() |
| 555 | method = 'GET' |
| 556 | |
| 557 | headers = self.extra_headers.copy() |
| 558 | headers.update(extra_headers or {}) |
| 559 | |
| 560 | if payload is not None: |
| 561 | method = 'POST' |
| 562 | headers['Content-Type'] = content_type |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 563 | |
| 564 | prev_timeout = self._http.timeout |
| 565 | try: |
| 566 | if timeout: |
| 567 | self._http.timeout = timeout |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 568 | url = self.host + request_path |
| 569 | if kwargs: |
| 570 | url += "?" + urllib.urlencode(kwargs) |
| 571 | |
[email protected] | 3fd55f9 | 2014-04-18 22:17:21 | [diff] [blame] | 572 | # This weird loop is there to detect when the OAuth2 token has expired. |
| 573 | # This is specific to appengine *and* rietveld. It relies on the |
| 574 | # assumption that a 302 is triggered only by an expired OAuth2 token. This |
| 575 | # prevents any usage of redirections in pages accessed this way. |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 576 | |
[email protected] | 3fd55f9 | 2014-04-18 22:17:21 | [diff] [blame] | 577 | # This variable is used to make sure the following loop runs only twice. |
| 578 | redirect_caught = False |
| 579 | while True: |
| 580 | try: |
| 581 | ret = self._http.request(url, |
| 582 | method=method, |
| 583 | body=payload, |
| 584 | headers=headers, |
| 585 | redirections=0) |
| 586 | except httplib2.RedirectLimit: |
| 587 | if redirect_caught or method != 'GET': |
| 588 | logging.error('Redirection detected after logging in. Giving up.') |
| 589 | raise |
| 590 | redirect_caught = True |
| 591 | logging.debug('Redirection detected. Trying to log in again...') |
| 592 | self.creds.access_token = None |
| 593 | continue |
| 594 | break |
| 595 | |
[email protected] | 1dda36d | 2016-02-11 00:28:39 | [diff] [blame] | 596 | if ret[0].status >= 300: |
| 597 | raise urllib2.HTTPError( |
| 598 | request_path, int(ret[0]['status']), ret[1], None, |
| 599 | StringIO.StringIO()) |
| 600 | |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 601 | return ret[1] |
| 602 | |
| 603 | finally: |
| 604 | self._http.timeout = prev_timeout |
| 605 | |
| 606 | |
| 607 | class JwtOAuth2Rietveld(Rietveld): |
| 608 | """Access to Rietveld using OAuth authentication. |
| 609 | |
| 610 | This class is supposed to be used only by bots, since this kind of |
| 611 | access is restricted to service accounts. |
| 612 | """ |
| 613 | # The parent__init__ is not called on purpose. |
Quinten Yearsley | b2cc4a9 | 2016-12-15 21:53:26 | [diff] [blame] | 614 | # pylint: disable=super-init-not-called |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 615 | def __init__(self, |
| 616 | url, |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 617 | client_email, |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 618 | client_private_key_file, |
| 619 | private_key_password=None, |
[email protected] | ab8154f | 2015-02-19 11:29:00 | [diff] [blame] | 620 | extra_headers=None, |
| 621 | maxtries=None): |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 622 | |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 623 | if private_key_password is None: # '' means 'empty password' |
| 624 | private_key_password = 'notasecret' |
| 625 | |
| 626 | self.url = url.rstrip('/') |
[email protected] | 39bb4b1 | 2015-06-17 15:53:24 | [diff] [blame] | 627 | bot_url = self.url |
| 628 | if self.url.endswith('googleplex.com'): |
| 629 | bot_url = self.url + '/bots' |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 630 | |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 631 | with open(client_private_key_file, 'rb') as f: |
| 632 | client_private_key = f.read() |
[email protected] | 92c3009 | 2014-04-15 00:30:37 | [diff] [blame] | 633 | logging.info('Using OAuth login: %s' % client_email) |
| 634 | self.rpc_server = OAuthRpcServer(bot_url, |
| 635 | client_email, |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 636 | client_private_key, |
| 637 | private_key_password=private_key_password, |
| 638 | extra_headers=extra_headers or {}) |
| 639 | self._xsrf_token = None |
| 640 | self._xsrf_token_time = None |
| 641 | |
[email protected] | db98b6e | 2015-02-19 11:42:45 | [diff] [blame] | 642 | self._maxtries = maxtries or 40 |
[email protected] | ab8154f | 2015-02-19 11:29:00 | [diff] [blame] | 643 | |
[email protected] | 9979824 | 2014-03-26 18:44:43 | [diff] [blame] | 644 | |
[email protected] | 4bac4b5 | 2012-11-27 20:33:52 | [diff] [blame] | 645 | class CachingRietveld(Rietveld): |
| 646 | """Caches the common queries. |
| 647 | |
| 648 | Not to be used in long-standing processes, like the commit queue. |
| 649 | """ |
| 650 | def __init__(self, *args, **kwargs): |
| 651 | super(CachingRietveld, self).__init__(*args, **kwargs) |
| 652 | self._cache = {} |
| 653 | |
| 654 | def _lookup(self, function_name, args, update): |
| 655 | """Caches the return values corresponding to the arguments. |
| 656 | |
| 657 | It is important that the arguments are standardized, like None vs False. |
| 658 | """ |
| 659 | function_cache = self._cache.setdefault(function_name, {}) |
| 660 | if args not in function_cache: |
| 661 | function_cache[args] = update(*args) |
| 662 | return copy.deepcopy(function_cache[args]) |
| 663 | |
Kenneth Russell | 61e2ed4 | 2017-02-15 19:47:13 | [diff] [blame] | 664 | def get_description(self, issue, force=False): |
| 665 | if force: |
| 666 | return super(CachingRietveld, self).get_description(issue, force=force) |
| 667 | else: |
| 668 | return self._lookup( |
| 669 | 'get_description', |
| 670 | (issue,), |
| 671 | super(CachingRietveld, self).get_description) |
[email protected] | 4bac4b5 | 2012-11-27 20:33:52 | [diff] [blame] | 672 | |
| 673 | def get_issue_properties(self, issue, messages): |
| 674 | """Returns the issue properties. |
| 675 | |
| 676 | Because in practice the presubmit checks often ask without messages first |
| 677 | and then with messages, always ask with messages and strip off if not asked |
| 678 | for the messages. |
| 679 | """ |
| 680 | # It's a tad slower to request with the message but it's better than |
| 681 | # requesting the properties twice. |
| 682 | data = self._lookup( |
| 683 | 'get_issue_properties', |
| 684 | (issue, True), |
| 685 | super(CachingRietveld, self).get_issue_properties) |
| 686 | if not messages: |
| 687 | # Assumes self._lookup uses deepcopy. |
| 688 | del data['messages'] |
| 689 | return data |
| 690 | |
| 691 | def get_patchset_properties(self, issue, patchset): |
| 692 | return self._lookup( |
| 693 | 'get_patchset_properties', |
| 694 | (issue, patchset), |
| 695 | super(CachingRietveld, self).get_patchset_properties) |
[email protected] | c7a9efa | 2013-09-20 19:38:39 | [diff] [blame] | 696 | |
| 697 | |
| 698 | class ReadOnlyRietveld(object): |
| 699 | """ |
| 700 | Only provides read operations, and simulates writes locally. |
| 701 | |
| 702 | Intentionally do not inherit from Rietveld to avoid any write-issuing |
| 703 | logic to be invoked accidentally. |
| 704 | """ |
| 705 | |
| 706 | # Dictionary of local changes, indexed by issue number as int. |
| 707 | _local_changes = {} |
| 708 | |
| 709 | def __init__(self, *args, **kwargs): |
| 710 | # We still need an actual Rietveld instance to issue reads, just keep |
| 711 | # it hidden. |
| 712 | self._rietveld = Rietveld(*args, **kwargs) |
| 713 | |
| 714 | @classmethod |
| 715 | def _get_local_changes(cls, issue): |
| 716 | """Returns dictionary of local changes for |issue|, if any.""" |
| 717 | return cls._local_changes.get(issue, {}) |
| 718 | |
| 719 | @property |
| 720 | def url(self): |
| 721 | return self._rietveld.url |
| 722 | |
[email protected] | c7a9efa | 2013-09-20 19:38:39 | [diff] [blame] | 723 | def get_pending_issues(self): |
| 724 | pending_issues = self._rietveld.get_pending_issues() |
| 725 | |
| 726 | # Filter out issues we've closed or unchecked the commit checkbox. |
| 727 | return [issue for issue in pending_issues |
| 728 | if not self._get_local_changes(issue).get('closed', False) and |
| 729 | self._get_local_changes(issue).get('commit', True)] |
| 730 | |
Quinten Yearsley | b2cc4a9 | 2016-12-15 21:53:26 | [diff] [blame] | 731 | def close_issue(self, issue): # pylint:disable=no-self-use |
[email protected] | c7a9efa | 2013-09-20 19:38:39 | [diff] [blame] | 732 | logging.info('ReadOnlyRietveld: closing issue %d' % issue) |
| 733 | ReadOnlyRietveld._local_changes.setdefault(issue, {})['closed'] = True |
| 734 | |
| 735 | def get_issue_properties(self, issue, messages): |
| 736 | data = self._rietveld.get_issue_properties(issue, messages) |
| 737 | data.update(self._get_local_changes(issue)) |
| 738 | return data |
| 739 | |
| 740 | def get_patchset_properties(self, issue, patchset): |
| 741 | return self._rietveld.get_patchset_properties(issue, patchset) |
| 742 | |
[email protected] | d91b7e3 | 2015-06-23 11:24:07 | [diff] [blame] | 743 | def get_depends_on_patchset(self, issue, patchset): |
| 744 | return self._rietveld.get_depends_on_patchset(issue, patchset) |
| 745 | |
[email protected] | c7a9efa | 2013-09-20 19:38:39 | [diff] [blame] | 746 | def get_patch(self, issue, patchset): |
| 747 | return self._rietveld.get_patch(issue, patchset) |
| 748 | |
Quinten Yearsley | b2cc4a9 | 2016-12-15 21:53:26 | [diff] [blame] | 749 | def update_description(self, issue, description): # pylint:disable=no-self-use |
[email protected] | c7a9efa | 2013-09-20 19:38:39 | [diff] [blame] | 750 | logging.info('ReadOnlyRietveld: new description for issue %d: %s' % |
| 751 | (issue, description)) |
| 752 | |
Quinten Yearsley | b2cc4a9 | 2016-12-15 21:53:26 | [diff] [blame] | 753 | def add_comment(self, # pylint:disable=no-self-use |
[email protected] | c7a9efa | 2013-09-20 19:38:39 | [diff] [blame] | 754 | issue, |
| 755 | message, |
| 756 | add_as_reviewer=False): |
| 757 | logging.info('ReadOnlyRietveld: posting comment "%s" to issue %d' % |
| 758 | (message, issue)) |
| 759 | |
Quinten Yearsley | b2cc4a9 | 2016-12-15 21:53:26 | [diff] [blame] | 760 | def set_flag(self, issue, patchset, flag, value): # pylint:disable=no-self-use |
[email protected] | c7a9efa | 2013-09-20 19:38:39 | [diff] [blame] | 761 | logging.info('ReadOnlyRietveld: setting flag "%s" to "%s" for issue %d' % |
| 762 | (flag, value, issue)) |
| 763 | ReadOnlyRietveld._local_changes.setdefault(issue, {})[flag] = value |
| 764 | |
tandrii | 4b233bd | 2016-07-06 10:50:29 | [diff] [blame] | 765 | def set_flags(self, issue, patchset, flags): |
| 766 | for flag, value in flags.iteritems(): |
| 767 | self.set_flag(issue, patchset, flag, value) |
| 768 | |
Quinten Yearsley | b2cc4a9 | 2016-12-15 21:53:26 | [diff] [blame] | 769 | def trigger_try_jobs( # pylint:disable=no-self-use |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 770 | self, issue, patchset, reason, clobber, revision, builders_and_tests, |
[email protected] | 62554f9 | 2015-01-06 00:42:39 | [diff] [blame] | 771 | master=None, category='cq'): |
[email protected] | c7a9efa | 2013-09-20 19:38:39 | [diff] [blame] | 772 | logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' % |
| 773 | (builders_and_tests, issue)) |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 774 | |
Quinten Yearsley | b2cc4a9 | 2016-12-15 21:53:26 | [diff] [blame] | 775 | def trigger_distributed_try_jobs( # pylint:disable=no-self-use |
[email protected] | 62554f9 | 2015-01-06 00:42:39 | [diff] [blame] | 776 | self, issue, patchset, reason, clobber, revision, masters, |
| 777 | category='cq'): |
[email protected] | 58a69cb | 2014-03-01 02:08:29 | [diff] [blame] | 778 | logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' % |
| 779 | (masters, issue)) |