blob: 9f0cdcedd8099213e10fb4abfe48d2803823a0bc [file] [log] [blame]
[email protected]2c96af72012-05-04 13:19:031# coding: utf-8
[email protected]9799a072012-01-11 00:26:252# Copyright (c) 2012 The Chromium Authors. All rights reserved.
[email protected]b3727a32011-04-04 19:31:443# 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
7Security implications:
8
9The 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]4bac4b52012-11-27 20:33:5217import copy
[email protected]c15d2a02015-10-02 19:41:3918import errno
[email protected]4f6852c2012-04-20 20:39:2019import json
[email protected]b3727a32011-04-04 19:31:4420import logging
[email protected]03152412011-09-01 14:42:4921import re
[email protected]c15d2a02015-10-02 19:41:3922import socket
[email protected]36bc3842014-02-25 22:36:1323import ssl
[email protected]1dda36d2016-02-11 00:28:3924import StringIO
[email protected]c15d2a02015-10-02 19:41:3925import sys
[email protected]b3727a32011-04-04 19:31:4426import time
[email protected]99798242014-03-26 18:44:4327import urllib
[email protected]b3727a32011-04-04 19:31:4428import urllib2
[email protected]99798242014-03-26 18:44:4329import urlparse
30
31import patch
[email protected]b3727a32011-04-04 19:31:4432
[email protected]b3727a32011-04-04 19:31:4433from third_party import upload
[email protected]99798242014-03-26 18:44:4334import third_party.oauth2client.client as oa2client
35from third_party import httplib2
[email protected]b3727a32011-04-04 19:31:4436
[email protected]e7f4e022014-04-17 21:19:3637# Appengine replies with 302 when authentication fails (sigh.)
38oa2client.REFRESH_STATUS_CODES.append(302)
[email protected]333087e2014-04-09 20:19:2939upload.LOGGER.setLevel(logging.WARNING) # pylint: disable=E1103
[email protected]b3727a32011-04-04 19:31:4440
41
42class Rietveld(object):
43 """Accesses rietveld."""
[email protected]cf6a5d22015-04-09 22:02:0044 def __init__(
45 self, url, auth_config, email=None, extra_headers=None, maxtries=None):
[email protected]3e9e4322011-09-28 00:11:3146 self.url = url.rstrip('/')
[email protected]cf6a5d22015-04-09 22:02:0047 self.rpc_server = upload.GetRpcServer(self.url, auth_config, email)
[email protected]ed233252012-07-06 17:25:1148
[email protected]b3727a32011-04-04 19:31:4449 self._xsrf_token = None
50 self._xsrf_token_time = None
[email protected]b3727a32011-04-04 19:31:4451
[email protected]ab8154f2015-02-19 11:29:0052 self._maxtries = maxtries or 40
53
[email protected]b3727a32011-04-04 19:31:4454 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]b8128282012-11-09 00:45:4865 # 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]b3727a32011-04-04 19:31:4469
70 def close_issue(self, issue):
71 """Closes the Rietveld issue for this changelist."""
[email protected]8f4e5bf2012-08-22 12:00:3672 logging.info('closing issue %d' % issue)
[email protected]b3727a32011-04-04 19:31:4473 self.post("/%d/close" % issue, [('xsrf_token', self.xsrf_token())])
74
Kenneth Russell61e2ed42017-02-15 19:47:1375 def get_description(self, issue, force=False):
[email protected]4572a092013-05-09 21:30:4676 """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]b3727a32011-04-04 19:31:4481
82 def get_issue_properties(self, issue, messages):
83 """Returns all the issue's metadata as a dictionary."""
[email protected]8f4e5bf2012-08-22 12:00:3684 url = '/api/%d' % issue
[email protected]b3727a32011-04-04 19:31:4485 if messages:
86 url += '?messages=true'
[email protected]d612e492014-08-27 14:00:4187 data = json.loads(self.get(url, retry_on_404=True))
[email protected]4572a092013-05-09 21:30:4688 data['description'] = '\n'.join(data['description'].strip().splitlines())
89 return data
[email protected]b3727a32011-04-04 19:31:4490
[email protected]d91b7e32015-06-23 11:24:0791 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]816d0852015-08-25 16:57:0296 resp = json.loads(self.post(url, []))
[email protected]d91b7e32015-06-23 11:24:0797 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]b3727a32011-04-04 19:31:44105 def get_patchset_properties(self, issue, patchset):
106 """Returns the patchset properties."""
[email protected]8f4e5bf2012-08-22 12:00:36107 url = '/api/%d/%d' % (issue, patchset)
[email protected]b3727a32011-04-04 19:31:44108 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]8f4e5bf2012-08-22 12:00:36117 url = '/%d/binary/%d/%d/%d' % (issue, patchset, item, content)
[email protected]b3727a32011-04-04 19:31:44118 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]8f4e5bf2012-08-22 12:00:36125 url = '/download/issue%d_%d_%d.diff' % (issue, patchset, item)
[email protected]b3727a32011-04-04 19:31:44126 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]61e0b692011-04-12 21:01:01133 logging.debug('%s' % filename)
[email protected]264952a2012-05-01 18:32:47134 # 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]40f4ad32012-05-08 21:29:24137 if status[0] not in ('A', 'D', 'M', 'R'):
[email protected]087066c2011-09-08 20:35:13138 raise patch.UnsupportedPatchFormat(
139 filename, 'Change with status \'%s\' is not supported.' % status)
[email protected]b3727a32011-04-04 19:31:44140
[email protected]087066c2011-09-08 20:35:13141 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]de85d9c2012-10-03 19:10:40151 content = self.get_file_content(issue, patchset, state['id'])
[email protected]03d762f2016-01-22 21:13:23152 if not content or content == 'None':
[email protected]de85d9c2012-10-03 19:10:40153 # 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]8bbdb7e2013-09-11 00:49:17159 out.append(patch.FilePatchBinary(
[email protected]b3727a32011-04-04 19:31:44160 filename,
[email protected]8bbdb7e2013-09-11 00:49:17161 content,
162 svn_props,
163 is_new=(status[0] == 'A')))
[email protected]087066c2011-09-08 20:35:13164 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]6cdac9e2011-09-07 14:25:40183 raise patch.UnsupportedPatchFormat(
[email protected]087066c2011-09-08 20:35:13184 filename, 'Failed to process the svn properties')
[email protected]6cdac9e2011-09-07 14:25:40185
[email protected]b3727a32011-04-04 19:31:44186 return patch.PatchSet(out)
187
[email protected]03152412011-09-01 14:42:49188 @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]03152412011-09-01 14:42:49204
[email protected]e2335ae2011-09-08 18:52:33205 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]03152412011-09-01 14:42:49213 # 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]9799a072012-01-11 00:26:25219 filename,
220 'Failed to parse svn properties: %s, %s' % (action, svn_props))
[email protected]03152412011-09-01 14:42:49221
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]9799a072012-01-11 00:26:25232 if match.group(2) in ('svn:eol-style', 'svn:executable', 'svn:mime-type'):
[email protected]03152412011-09-01 14:42:49233 # ' + 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]b3727a32011-04-04 19:31:44242 def update_description(self, issue, description):
243 """Sets the description for an issue on Rietveld."""
[email protected]8f4e5bf2012-08-22 12:00:36244 logging.info('new description for issue %d' % issue)
245 self.post('/%d/description' % issue, [
[email protected]b3727a32011-04-04 19:31:44246 ('description', description),
247 ('xsrf_token', self.xsrf_token())])
248
[email protected]89398202012-09-06 07:37:10249 def add_comment(self, issue, message, add_as_reviewer=False):
[email protected]2c96af72012-05-04 13:19:03250 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]0df1e0d2013-01-18 18:21:33254 logging.info('issue %d; comment: %s' % (issue, message.strip()[:300]))
[email protected]8f4e5bf2012-08-22 12:00:36255 return self.post('/%d/publish' % issue, [
[email protected]b3727a32011-04-04 19:31:44256 ('xsrf_token', self.xsrf_token()),
257 ('message', message),
258 ('message_only', 'True'),
[email protected]89398202012-09-06 07:37:10259 ('add_as_reviewer', str(bool(add_as_reviewer))),
[email protected]b3727a32011-04-04 19:31:44260 ('send_mail', 'True'),
261 ('no_redirect', 'True')])
262
[email protected]ac98e292014-01-13 17:48:49263 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]b3727a32011-04-04 19:31:44275 def set_flag(self, issue, patchset, flag, value):
[email protected]8f4e5bf2012-08-22 12:00:36276 return self.post('/%d/edit_flags' % issue, [
[email protected]b3727a32011-04-04 19:31:44277 ('last_patchset', str(patchset)),
278 ('xsrf_token', self.xsrf_token()),
[email protected]de9c6752013-09-27 19:07:47279 (flag, str(value))])
[email protected]b3727a32011-04-04 19:31:44280
tandrii4b233bd2016-07-06 10:50:29281 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]c73e5162011-09-21 23:16:12287 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 Shyshkalove6e23752017-04-19 22:14:03313 # 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]c73e5162011-09-21 23:16:12320
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 Shyshkalove6e23752017-04-19 22:14:03330 url += '&%s=%d' % (key, three_state_value_map[value])
[email protected]c73e5162011-09-21 23:16:12331
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]61ea42f2012-09-05 14:58:52352 def trigger_try_jobs(
[email protected]58a69cb2014-03-01 02:08:29353 self, issue, patchset, reason, clobber, revision, builders_and_tests,
[email protected]62554f92015-01-06 00:42:39354 master=None, category='cq'):
[email protected]61ea42f2012-09-05 14:58:52355 """Requests new try jobs.
356
357 |builders_and_tests| is a map of builders: [tests] to run.
[email protected]58a69cb2014-03-01 02:08:29358 |master| is the name of the try master the builders belong to.
[email protected]62554f92015-01-06 00:42:39359 |category| is used to distinguish regular jobs and experimental jobs.
[email protected]61ea42f2012-09-05 14:58:52360
361 Returns the keys of the new TryJobResult entites.
362 """
363 params = [
364 ('reason', reason),
365 ('clobber', 'True' if clobber else 'False'),
[email protected]61ea42f2012-09-05 14:58:52366 ('builders', json.dumps(builders_and_tests)),
367 ('xsrf_token', self.xsrf_token()),
[email protected]62554f92015-01-06 00:42:39368 ('category', category),
[email protected]61ea42f2012-09-05 14:58:52369 ]
[email protected]072d94b2012-09-20 19:20:08370 if revision:
371 params.append(('revision', revision))
[email protected]58a69cb2014-03-01 02:08:29372 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]61ea42f2012-09-05 14:58:52377 return self.post('/%d/try/%d' % (issue, patchset), params)
378
[email protected]58a69cb2014-03-01 02:08:29379 def trigger_distributed_try_jobs(
[email protected]62554f92015-01-06 00:42:39380 self, issue, patchset, reason, clobber, revision, masters,
381 category='cq'):
[email protected]58a69cb2014-03-01 02:08:29382 """Requests new try jobs.
383
384 |masters| is a map of masters: map of builders: [tests] to run.
[email protected]62554f92015-01-06 00:42:39385 |category| is used to distinguish regular jobs and experimental jobs.
[email protected]58a69cb2014-03-01 02:08:29386 """
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]62554f92015-01-06 00:42:39390 master, category)
[email protected]58a69cb2014-03-01 02:08:29391
[email protected]61ea42f2012-09-05 14:58:52392 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]b3727a32011-04-04 19:31:44402 def get(self, request_path, **kwargs):
[email protected]cab38e92011-04-09 00:30:51403 kwargs.setdefault('payload', None)
404 return self._send(request_path, **kwargs)
[email protected]b3727a32011-04-04 19:31:44405
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]d612e492014-08-27 14:00:41410 def _send(self, request_path, retry_on_404=False, **kwargs):
[email protected]b3727a32011-04-04 19:31:44411 """Sends a POST/GET to Rietveld. Returns the response body."""
[email protected]e0faffa2014-02-21 01:55:31412 # rpc_server.Send() assumes timeout=None by default; make sure it's set
413 # to something reasonable.
414 kwargs.setdefault('timeout', 15)
[email protected]eebd3c92013-01-07 18:34:49415 logging.debug('POSTing to %s, args %s.', request_path, kwargs)
[email protected]a01fd322012-02-27 19:11:08416 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]cef72362013-10-15 22:52:59425 raise urllib2.HTTPError(
[email protected]1dda36d2016-02-11 00:28:39426 request_path, int(m.group(1)), msg, None, StringIO.StringIO())
[email protected]a01fd322012-02-27 19:11:08427 old_error_exit(msg)
428 upload.ErrorExit = trap_http_500
429
[email protected]ab8154f2015-02-19 11:29:00430 for retry in xrange(self._maxtries):
[email protected]a01fd322012-02-27 19:11:08431 try:
432 logging.debug('%s' % request_path)
[email protected]1dda36d2016-02-11 00:28:39433 return self.rpc_server.Send(request_path, **kwargs)
[email protected]a01fd322012-02-27 19:11:08434 except urllib2.HTTPError, e:
[email protected]ab8154f2015-02-19 11:29:00435 if retry >= (self._maxtries - 1):
[email protected]a01fd322012-02-27 19:11:08436 raise
[email protected]1dda36d2016-02-11 00:28:39437 flake_codes = {500, 502, 503}
[email protected]d612e492014-08-27 14:00:41438 if retry_on_404:
[email protected]1dda36d2016-02-11 00:28:39439 flake_codes.add(404)
[email protected]d612e492014-08-27 14:00:41440 if e.code not in flake_codes:
[email protected]a01fd322012-02-27 19:11:08441 raise
442 except urllib2.URLError, e:
[email protected]ab8154f2015-02-19 11:29:00443 if retry >= (self._maxtries - 1):
[email protected]a01fd322012-02-27 19:11:08444 raise
[email protected]29d5e562015-12-03 15:00:34445
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]1dda36d2016-02-11 00:28:39454 for retry_anyway in (
[email protected]29d5e562015-12-03 15:00:34455 'Name or service not known',
456 'EOF occurred in violation of protocol',
[email protected]432fb942016-04-11 17:29:46457 'timed out',
458 # See http://crbug.com/601260.
Andrii Shyshkalov1bf69a12016-11-29 17:26:23459 '[Errno 10060] A connection attempt failed',
460 '[Errno 104] Connection reset by peer',
[email protected]432fb942016-04-11 17:29:46461 ):
[email protected]29d5e562015-12-03 15:00:34462 if retry_anyway in reason_as_str:
463 return True
464 return False # Assume permanent otherwise.
465 if not is_transient():
Andrii Shyshkalov1bf69a12016-11-29 17:26:23466 logging.error('Caught urllib2.URLError %s which wasn\'t deemed '
467 'transient', e.reason)
[email protected]a01fd322012-02-27 19:11:08468 raise
[email protected]f4ef3e72015-10-22 00:39:32469 except socket.error, e:
[email protected]ab8154f2015-02-19 11:29:00470 if retry >= (self._maxtries - 1):
[email protected]36bc3842014-02-25 22:36:13471 raise
[email protected]04bd6b12014-02-26 01:04:04472 if not 'timed out' in str(e):
[email protected]36bc3842014-02-25 22:36:13473 raise
[email protected]a01fd322012-02-27 19:11:08474 # If reaching this line, loop again. Uses a small backoff.
[email protected]538f6022014-06-13 17:08:01475 time.sleep(min(10, 1+retry*2))
[email protected]e4d195a2015-10-05 15:45:18476 except urllib2.HTTPError as e:
477 print 'Request to %s failed: %s' % (e.geturl(), e.read())
478 raise
[email protected]a01fd322012-02-27 19:11:08479 finally:
480 upload.ErrorExit = old_error_exit
[email protected]cab38e92011-04-09 00:30:51481
482 # DEPRECATED.
483 Send = get
[email protected]4bac4b52012-11-27 20:33:52484
485
[email protected]99798242014-03-26 18:44:43486class OAuthRpcServer(object):
487 def __init__(self,
488 host,
[email protected]92c30092014-04-15 00:30:37489 client_email,
[email protected]99798242014-03-26 18:44:43490 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]92c30092014-04-15 00:30:37497 client_email: email associated with the service account
[email protected]99798242014-03-26 18:44:43498 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]92c30092014-04-15 00:30:37520 logging.error("No support for OpenSSL has been found, "
[email protected]99798242014-03-26 18:44:43521 "OAuth2 support requires it.")
522 logging.error("Installing pyopenssl will probably solve this issue.")
523 raise RuntimeError('No OpenSSL support')
[email protected]3fd55f92014-04-18 22:17:21524 self.creds = oa2client.SignedJwtAssertionCredentials(
[email protected]92c30092014-04-15 00:30:37525 client_email,
[email protected]99798242014-03-26 18:44:43526 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]3fd55f92014-04-18 22:17:21531 self._http = self.creds.authorize(httplib2.Http(timeout=timeout))
[email protected]99798242014-03-26 18:44:43532
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]1dda36d2016-02-11 00:28:39548
549 Returns: the HTTP response body as a string
550
551 Raises:
552 urllib2.HTTPError
[email protected]99798242014-03-26 18:44:43553 """
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]99798242014-03-26 18:44:43563
564 prev_timeout = self._http.timeout
565 try:
566 if timeout:
567 self._http.timeout = timeout
[email protected]99798242014-03-26 18:44:43568 url = self.host + request_path
569 if kwargs:
570 url += "?" + urllib.urlencode(kwargs)
571
[email protected]3fd55f92014-04-18 22:17:21572 # 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]92c30092014-04-15 00:30:37576
[email protected]3fd55f92014-04-18 22:17:21577 # 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]1dda36d2016-02-11 00:28:39596 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]99798242014-03-26 18:44:43601 return ret[1]
602
603 finally:
604 self._http.timeout = prev_timeout
605
606
607class 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 Yearsleyb2cc4a92016-12-15 21:53:26614 # pylint: disable=super-init-not-called
[email protected]99798242014-03-26 18:44:43615 def __init__(self,
616 url,
[email protected]92c30092014-04-15 00:30:37617 client_email,
[email protected]99798242014-03-26 18:44:43618 client_private_key_file,
619 private_key_password=None,
[email protected]ab8154f2015-02-19 11:29:00620 extra_headers=None,
621 maxtries=None):
[email protected]92c30092014-04-15 00:30:37622
[email protected]99798242014-03-26 18:44:43623 if private_key_password is None: # '' means 'empty password'
624 private_key_password = 'notasecret'
625
626 self.url = url.rstrip('/')
[email protected]39bb4b12015-06-17 15:53:24627 bot_url = self.url
628 if self.url.endswith('googleplex.com'):
629 bot_url = self.url + '/bots'
[email protected]92c30092014-04-15 00:30:37630
[email protected]99798242014-03-26 18:44:43631 with open(client_private_key_file, 'rb') as f:
632 client_private_key = f.read()
[email protected]92c30092014-04-15 00:30:37633 logging.info('Using OAuth login: %s' % client_email)
634 self.rpc_server = OAuthRpcServer(bot_url,
635 client_email,
[email protected]99798242014-03-26 18:44:43636 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]db98b6e2015-02-19 11:42:45642 self._maxtries = maxtries or 40
[email protected]ab8154f2015-02-19 11:29:00643
[email protected]99798242014-03-26 18:44:43644
[email protected]4bac4b52012-11-27 20:33:52645class 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 Russell61e2ed42017-02-15 19:47:13664 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]4bac4b52012-11-27 20:33:52672
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]c7a9efa2013-09-20 19:38:39696
697
698class 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]c7a9efa2013-09-20 19:38:39723 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 Yearsleyb2cc4a92016-12-15 21:53:26731 def close_issue(self, issue): # pylint:disable=no-self-use
[email protected]c7a9efa2013-09-20 19:38:39732 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]d91b7e32015-06-23 11:24:07743 def get_depends_on_patchset(self, issue, patchset):
744 return self._rietveld.get_depends_on_patchset(issue, patchset)
745
[email protected]c7a9efa2013-09-20 19:38:39746 def get_patch(self, issue, patchset):
747 return self._rietveld.get_patch(issue, patchset)
748
Quinten Yearsleyb2cc4a92016-12-15 21:53:26749 def update_description(self, issue, description): # pylint:disable=no-self-use
[email protected]c7a9efa2013-09-20 19:38:39750 logging.info('ReadOnlyRietveld: new description for issue %d: %s' %
751 (issue, description))
752
Quinten Yearsleyb2cc4a92016-12-15 21:53:26753 def add_comment(self, # pylint:disable=no-self-use
[email protected]c7a9efa2013-09-20 19:38:39754 issue,
755 message,
756 add_as_reviewer=False):
757 logging.info('ReadOnlyRietveld: posting comment "%s" to issue %d' %
758 (message, issue))
759
Quinten Yearsleyb2cc4a92016-12-15 21:53:26760 def set_flag(self, issue, patchset, flag, value): # pylint:disable=no-self-use
[email protected]c7a9efa2013-09-20 19:38:39761 logging.info('ReadOnlyRietveld: setting flag "%s" to "%s" for issue %d' %
762 (flag, value, issue))
763 ReadOnlyRietveld._local_changes.setdefault(issue, {})[flag] = value
764
tandrii4b233bd2016-07-06 10:50:29765 def set_flags(self, issue, patchset, flags):
766 for flag, value in flags.iteritems():
767 self.set_flag(issue, patchset, flag, value)
768
Quinten Yearsleyb2cc4a92016-12-15 21:53:26769 def trigger_try_jobs( # pylint:disable=no-self-use
[email protected]58a69cb2014-03-01 02:08:29770 self, issue, patchset, reason, clobber, revision, builders_and_tests,
[email protected]62554f92015-01-06 00:42:39771 master=None, category='cq'):
[email protected]c7a9efa2013-09-20 19:38:39772 logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
773 (builders_and_tests, issue))
[email protected]58a69cb2014-03-01 02:08:29774
Quinten Yearsleyb2cc4a92016-12-15 21:53:26775 def trigger_distributed_try_jobs( # pylint:disable=no-self-use
[email protected]62554f92015-01-06 00:42:39776 self, issue, patchset, reason, clobber, revision, masters,
777 category='cq'):
[email protected]58a69cb2014-03-01 02:08:29778 logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
779 (masters, issue))