blob: d78c0171d468f789ca5be44b3aea1434cd4dbed8 [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]4f6852c2012-04-20 20:39:2018import json
[email protected]b3727a32011-04-04 19:31:4419import logging
[email protected]03152412011-09-01 14:42:4920import re
[email protected]36bc3842014-02-25 22:36:1321import ssl
[email protected]b3727a32011-04-04 19:31:4422import time
[email protected]99798242014-03-26 18:44:4323import urllib
[email protected]b3727a32011-04-04 19:31:4424import urllib2
[email protected]99798242014-03-26 18:44:4325import urlparse
26
27import patch
[email protected]b3727a32011-04-04 19:31:4428
[email protected]b3727a32011-04-04 19:31:4429from third_party import upload
[email protected]99798242014-03-26 18:44:4330import third_party.oauth2client.client as oa2client
31from third_party import httplib2
[email protected]b3727a32011-04-04 19:31:4432
[email protected]e7f4e022014-04-17 21:19:3633# Appengine replies with 302 when authentication fails (sigh.)
34oa2client.REFRESH_STATUS_CODES.append(302)
[email protected]333087e2014-04-09 20:19:2935upload.LOGGER.setLevel(logging.WARNING) # pylint: disable=E1103
[email protected]b3727a32011-04-04 19:31:4436
37
38class Rietveld(object):
39 """Accesses rietveld."""
[email protected]794a4f12011-05-27 12:43:2140 def __init__(self, url, email, password, extra_headers=None):
[email protected]3e9e4322011-09-28 00:11:3141 self.url = url.rstrip('/')
[email protected]782dc942014-03-27 19:06:3342 # Email and password are accessed by commit queue, keep them.
43 self.email = email
44 self.password = password
[email protected]239f4112011-06-03 20:08:2345 # TODO(maruel): It's not awesome but maybe necessary to retrieve the value.
46 # It happens when the presubmit check is ran out of process, the cookie
47 # needed to be recreated from the credentials. Instead, it should pass the
48 # email and the cookie.
[email protected]cab38e92011-04-09 00:30:5149 if email and password:
50 get_creds = lambda: (email, password)
51 self.rpc_server = upload.HttpRpcServer(
52 self.url,
[email protected]794a4f12011-05-27 12:43:2153 get_creds,
[email protected]7bbcfc32011-05-27 12:49:1654 extra_headers=extra_headers or {})
[email protected]cab38e92011-04-09 00:30:5155 else:
[email protected]ed233252012-07-06 17:25:1156 if email == '':
[email protected]083cd452012-09-03 17:37:1857 # If email is given as an empty string, then assume we want to make
58 # requests that do not need authentication. Bypass authentication by
[email protected]94145992013-07-27 02:32:1159 # setting the auth_function to None.
60 self.rpc_server = upload.HttpRpcServer(url, None)
[email protected]3bf4b3c2012-09-03 15:53:0261 else:
62 self.rpc_server = upload.GetRpcServer(url, email)
[email protected]ed233252012-07-06 17:25:1163
[email protected]b3727a32011-04-04 19:31:4464 self._xsrf_token = None
65 self._xsrf_token_time = None
[email protected]b3727a32011-04-04 19:31:4466
67 def xsrf_token(self):
68 if (not self._xsrf_token_time or
69 (time.time() - self._xsrf_token_time) > 30*60):
70 self._xsrf_token_time = time.time()
71 self._xsrf_token = self.get(
72 '/xsrf_token',
73 extra_headers={'X-Requesting-XSRF-Token': '1'})
74 return self._xsrf_token
75
76 def get_pending_issues(self):
77 """Returns an array of dict of all the pending issues on the server."""
[email protected]b8128282012-11-09 00:45:4878 # TODO: Convert this to use Rietveld::search(), defined below.
79 return json.loads(
80 self.get('/search?format=json&commit=2&closed=3&'
81 'keys_only=True&limit=1000&order=__key__'))['results']
[email protected]b3727a32011-04-04 19:31:4482
83 def close_issue(self, issue):
84 """Closes the Rietveld issue for this changelist."""
[email protected]8f4e5bf2012-08-22 12:00:3685 logging.info('closing issue %d' % issue)
[email protected]b3727a32011-04-04 19:31:4486 self.post("/%d/close" % issue, [('xsrf_token', self.xsrf_token())])
87
88 def get_description(self, issue):
[email protected]4572a092013-05-09 21:30:4689 """Returns the issue's description.
90
91 Converts any CRLF into LF and strip extraneous whitespace.
92 """
93 return '\n'.join(self.get('/%d/description' % issue).strip().splitlines())
[email protected]b3727a32011-04-04 19:31:4494
95 def get_issue_properties(self, issue, messages):
96 """Returns all the issue's metadata as a dictionary."""
[email protected]8f4e5bf2012-08-22 12:00:3697 url = '/api/%d' % issue
[email protected]b3727a32011-04-04 19:31:4498 if messages:
99 url += '?messages=true'
[email protected]4572a092013-05-09 21:30:46100 data = json.loads(self.get(url))
101 data['description'] = '\n'.join(data['description'].strip().splitlines())
102 return data
[email protected]b3727a32011-04-04 19:31:44103
104 def get_patchset_properties(self, issue, patchset):
105 """Returns the patchset properties."""
[email protected]8f4e5bf2012-08-22 12:00:36106 url = '/api/%d/%d' % (issue, patchset)
[email protected]b3727a32011-04-04 19:31:44107 return json.loads(self.get(url))
108
109 def get_file_content(self, issue, patchset, item):
110 """Returns the content of a new file.
111
112 Throws HTTP 302 exception if the file doesn't exist or is not a binary file.
113 """
114 # content = 0 is the old file, 1 is the new file.
115 content = 1
[email protected]8f4e5bf2012-08-22 12:00:36116 url = '/%d/binary/%d/%d/%d' % (issue, patchset, item, content)
[email protected]b3727a32011-04-04 19:31:44117 return self.get(url)
118
119 def get_file_diff(self, issue, patchset, item):
120 """Returns the diff of the file.
121
122 Returns a useless diff for binary files.
123 """
[email protected]8f4e5bf2012-08-22 12:00:36124 url = '/download/issue%d_%d_%d.diff' % (issue, patchset, item)
[email protected]b3727a32011-04-04 19:31:44125 return self.get(url)
126
127 def get_patch(self, issue, patchset):
128 """Returns a PatchSet object containing the details to apply this patch."""
129 props = self.get_patchset_properties(issue, patchset) or {}
130 out = []
131 for filename, state in props.get('files', {}).iteritems():
[email protected]61e0b692011-04-12 21:01:01132 logging.debug('%s' % filename)
[email protected]264952a2012-05-01 18:32:47133 # If not status, just assume it's a 'M'. Rietveld often gets it wrong and
134 # just has status: null. Oh well.
135 status = state.get('status') or 'M'
[email protected]40f4ad32012-05-08 21:29:24136 if status[0] not in ('A', 'D', 'M', 'R'):
[email protected]087066c2011-09-08 20:35:13137 raise patch.UnsupportedPatchFormat(
138 filename, 'Change with status \'%s\' is not supported.' % status)
[email protected]b3727a32011-04-04 19:31:44139
[email protected]087066c2011-09-08 20:35:13140 svn_props = self.parse_svn_properties(
141 state.get('property_changes', ''), filename)
142
143 if state.get('is_binary'):
144 if status[0] == 'D':
145 if status[0] != status.strip():
146 raise patch.UnsupportedPatchFormat(
147 filename, 'Deleted file shouldn\'t have property change.')
148 out.append(patch.FilePatchDelete(filename, state['is_binary']))
149 else:
[email protected]de85d9c2012-10-03 19:10:40150 content = self.get_file_content(issue, patchset, state['id'])
151 if not content:
152 # As a precaution due to a bug in upload.py for git checkout, refuse
153 # empty files. If it's empty, it's not a binary file.
154 raise patch.UnsupportedPatchFormat(
155 filename,
156 'Binary file is empty. Maybe the file wasn\'t uploaded in the '
157 'first place?')
[email protected]8bbdb7e2013-09-11 00:49:17158 out.append(patch.FilePatchBinary(
[email protected]b3727a32011-04-04 19:31:44159 filename,
[email protected]8bbdb7e2013-09-11 00:49:17160 content,
161 svn_props,
162 is_new=(status[0] == 'A')))
[email protected]087066c2011-09-08 20:35:13163 continue
164
165 try:
166 diff = self.get_file_diff(issue, patchset, state['id'])
167 except urllib2.HTTPError, e:
168 if e.code == 404:
169 raise patch.UnsupportedPatchFormat(
170 filename, 'File doesn\'t have a diff.')
171 raise
172
173 # FilePatchDiff() will detect file deletion automatically.
174 p = patch.FilePatchDiff(filename, diff, svn_props)
175 out.append(p)
176 if status[0] == 'A':
177 # It won't be set for empty file.
178 p.is_new = True
179 if (len(status) > 1 and
180 status[1] == '+' and
181 not (p.source_filename or p.svn_properties)):
[email protected]6cdac9e2011-09-07 14:25:40182 raise patch.UnsupportedPatchFormat(
[email protected]087066c2011-09-08 20:35:13183 filename, 'Failed to process the svn properties')
[email protected]6cdac9e2011-09-07 14:25:40184
[email protected]b3727a32011-04-04 19:31:44185 return patch.PatchSet(out)
186
[email protected]03152412011-09-01 14:42:49187 @staticmethod
188 def parse_svn_properties(rietveld_svn_props, filename):
189 """Returns a list of tuple [('property', 'newvalue')].
190
191 rietveld_svn_props is the exact format from 'svn diff'.
192 """
193 rietveld_svn_props = rietveld_svn_props.splitlines()
194 svn_props = []
195 if not rietveld_svn_props:
196 return svn_props
197 # 1. Ignore svn:mergeinfo.
198 # 2. Accept svn:eol-style and svn:executable.
199 # 3. Refuse any other.
200 # \n
201 # Added: svn:ignore\n
202 # + LF\n
[email protected]03152412011-09-01 14:42:49203
[email protected]e2335ae2011-09-08 18:52:33204 spacer = rietveld_svn_props.pop(0)
205 if spacer or not rietveld_svn_props:
206 # svn diff always put a spacer between the unified diff and property
207 # diff
208 raise patch.UnsupportedPatchFormat(
209 filename, 'Failed to parse svn properties.')
210
211 while rietveld_svn_props:
[email protected]03152412011-09-01 14:42:49212 # Something like 'Added: svn:eol-style'. Note the action is localized.
213 # *sigh*.
214 action = rietveld_svn_props.pop(0)
215 match = re.match(r'^(\w+): (.+)$', action)
216 if not match or not rietveld_svn_props:
217 raise patch.UnsupportedPatchFormat(
[email protected]9799a072012-01-11 00:26:25218 filename,
219 'Failed to parse svn properties: %s, %s' % (action, svn_props))
[email protected]03152412011-09-01 14:42:49220
221 if match.group(2) == 'svn:mergeinfo':
222 # Silently ignore the content.
223 rietveld_svn_props.pop(0)
224 continue
225
226 if match.group(1) not in ('Added', 'Modified'):
227 # Will fail for our French friends.
228 raise patch.UnsupportedPatchFormat(
229 filename, 'Unsupported svn property operation.')
230
[email protected]9799a072012-01-11 00:26:25231 if match.group(2) in ('svn:eol-style', 'svn:executable', 'svn:mime-type'):
[email protected]03152412011-09-01 14:42:49232 # ' + foo' where foo is the new value. That's fragile.
233 content = rietveld_svn_props.pop(0)
234 match2 = re.match(r'^ \+ (.*)$', content)
235 if not match2:
236 raise patch.UnsupportedPatchFormat(
237 filename, 'Unsupported svn property format.')
238 svn_props.append((match.group(2), match2.group(1)))
239 return svn_props
240
[email protected]b3727a32011-04-04 19:31:44241 def update_description(self, issue, description):
242 """Sets the description for an issue on Rietveld."""
[email protected]8f4e5bf2012-08-22 12:00:36243 logging.info('new description for issue %d' % issue)
244 self.post('/%d/description' % issue, [
[email protected]b3727a32011-04-04 19:31:44245 ('description', description),
246 ('xsrf_token', self.xsrf_token())])
247
[email protected]89398202012-09-06 07:37:10248 def add_comment(self, issue, message, add_as_reviewer=False):
[email protected]2c96af72012-05-04 13:19:03249 max_message = 10000
250 tail = '…\n(message too large)'
251 if len(message) > max_message:
252 message = message[:max_message-len(tail)] + tail
[email protected]0df1e0d2013-01-18 18:21:33253 logging.info('issue %d; comment: %s' % (issue, message.strip()[:300]))
[email protected]8f4e5bf2012-08-22 12:00:36254 return self.post('/%d/publish' % issue, [
[email protected]b3727a32011-04-04 19:31:44255 ('xsrf_token', self.xsrf_token()),
256 ('message', message),
257 ('message_only', 'True'),
[email protected]89398202012-09-06 07:37:10258 ('add_as_reviewer', str(bool(add_as_reviewer))),
[email protected]b3727a32011-04-04 19:31:44259 ('send_mail', 'True'),
260 ('no_redirect', 'True')])
261
[email protected]ac98e292014-01-13 17:48:49262 def add_inline_comment(
263 self, issue, text, side, snapshot, patchset, patchid, lineno):
264 logging.info('add inline comment for issue %d' % issue)
265 return self.post('/inline_draft', [
266 ('issue', str(issue)),
267 ('text', text),
268 ('side', side),
269 ('snapshot', snapshot),
270 ('patchset', str(patchset)),
271 ('patch', str(patchid)),
272 ('lineno', str(lineno))])
273
[email protected]b3727a32011-04-04 19:31:44274 def set_flag(self, issue, patchset, flag, value):
[email protected]8f4e5bf2012-08-22 12:00:36275 return self.post('/%d/edit_flags' % issue, [
[email protected]b3727a32011-04-04 19:31:44276 ('last_patchset', str(patchset)),
277 ('xsrf_token', self.xsrf_token()),
[email protected]de9c6752013-09-27 19:07:47278 (flag, str(value))])
[email protected]b3727a32011-04-04 19:31:44279
[email protected]c73e5162011-09-21 23:16:12280 def search(
281 self,
282 owner=None, reviewer=None,
283 base=None,
284 closed=None, private=None, commit=None,
285 created_before=None, created_after=None,
286 modified_before=None, modified_after=None,
287 per_request=None, keys_only=False,
288 with_messages=False):
289 """Yields search results."""
290 # These are expected to be strings.
291 string_keys = {
292 'owner': owner,
293 'reviewer': reviewer,
294 'base': base,
295 'created_before': created_before,
296 'created_after': created_after,
297 'modified_before': modified_before,
298 'modified_after': modified_after,
299 }
300 # These are either None, False or True.
301 three_state_keys = {
302 'closed': closed,
303 'private': private,
304 'commit': commit,
305 }
306
307 url = '/search?format=json'
308 # Sort the keys mainly to ease testing.
309 for key in sorted(string_keys):
310 value = string_keys[key]
311 if value:
312 url += '&%s=%s' % (key, urllib2.quote(value))
313 for key in sorted(three_state_keys):
314 value = three_state_keys[key]
315 if value is not None:
316 url += '&%s=%d' % (key, int(value) + 1)
317
318 if keys_only:
319 url += '&keys_only=True'
320 if with_messages:
321 url += '&with_messages=True'
322 if per_request:
323 url += '&limit=%d' % per_request
324
325 cursor = ''
326 while True:
327 output = self.get(url + cursor)
328 if output.startswith('<'):
329 # It's an error message. Return as no result.
330 break
331 data = json.loads(output) or {}
332 if not data.get('results'):
333 break
334 for i in data['results']:
335 yield i
336 cursor = '&cursor=%s' % data['cursor']
337
[email protected]61ea42f2012-09-05 14:58:52338 def trigger_try_jobs(
[email protected]58a69cb2014-03-01 02:08:29339 self, issue, patchset, reason, clobber, revision, builders_and_tests,
340 master=None):
[email protected]61ea42f2012-09-05 14:58:52341 """Requests new try jobs.
342
343 |builders_and_tests| is a map of builders: [tests] to run.
[email protected]58a69cb2014-03-01 02:08:29344 |master| is the name of the try master the builders belong to.
[email protected]61ea42f2012-09-05 14:58:52345
346 Returns the keys of the new TryJobResult entites.
347 """
348 params = [
349 ('reason', reason),
350 ('clobber', 'True' if clobber else 'False'),
[email protected]61ea42f2012-09-05 14:58:52351 ('builders', json.dumps(builders_and_tests)),
352 ('xsrf_token', self.xsrf_token()),
353 ]
[email protected]072d94b2012-09-20 19:20:08354 if revision:
355 params.append(('revision', revision))
[email protected]58a69cb2014-03-01 02:08:29356 if master:
357 # Temporarily allow empty master names for old configurations. The try
358 # job will not be associated with a master name on rietveld. This is
359 # going to be deprecated.
360 params.append(('master', master))
[email protected]61ea42f2012-09-05 14:58:52361 return self.post('/%d/try/%d' % (issue, patchset), params)
362
[email protected]58a69cb2014-03-01 02:08:29363 def trigger_distributed_try_jobs(
364 self, issue, patchset, reason, clobber, revision, masters):
365 """Requests new try jobs.
366
367 |masters| is a map of masters: map of builders: [tests] to run.
368 """
369 for (master, builders_and_tests) in masters.iteritems():
370 self.trigger_try_jobs(
371 issue, patchset, reason, clobber, revision, builders_and_tests,
372 master)
373
[email protected]61ea42f2012-09-05 14:58:52374 def get_pending_try_jobs(self, cursor=None, limit=100):
375 """Retrieves the try job requests in pending state.
376
377 Returns a tuple of the list of try jobs and the cursor for the next request.
378 """
379 url = '/get_pending_try_patchsets?limit=%d' % limit
380 extra = ('&cursor=' + cursor) if cursor else ''
381 data = json.loads(self.get(url + extra))
382 return data['jobs'], data['cursor']
383
[email protected]b3727a32011-04-04 19:31:44384 def get(self, request_path, **kwargs):
[email protected]cab38e92011-04-09 00:30:51385 kwargs.setdefault('payload', None)
386 return self._send(request_path, **kwargs)
[email protected]b3727a32011-04-04 19:31:44387
388 def post(self, request_path, data, **kwargs):
389 ctype, body = upload.EncodeMultipartFormData(data, [])
390 return self._send(request_path, payload=body, content_type=ctype, **kwargs)
391
392 def _send(self, request_path, **kwargs):
393 """Sends a POST/GET to Rietveld. Returns the response body."""
[email protected]e0faffa2014-02-21 01:55:31394 # rpc_server.Send() assumes timeout=None by default; make sure it's set
395 # to something reasonable.
396 kwargs.setdefault('timeout', 15)
[email protected]eebd3c92013-01-07 18:34:49397 logging.debug('POSTing to %s, args %s.', request_path, kwargs)
[email protected]a01fd322012-02-27 19:11:08398 try:
399 # Sadly, upload.py calls ErrorExit() which does a sys.exit(1) on HTTP
400 # 500 in AbstractRpcServer.Send().
401 old_error_exit = upload.ErrorExit
402 def trap_http_500(msg):
403 """Converts an incorrect ErrorExit() call into a HTTPError exception."""
404 m = re.search(r'(50\d) Server Error', msg)
405 if m:
406 # Fake an HTTPError exception. Cheezy. :(
[email protected]cef72362013-10-15 22:52:59407 raise urllib2.HTTPError(
408 request_path, int(m.group(1)), msg, None, None)
[email protected]a01fd322012-02-27 19:11:08409 old_error_exit(msg)
410 upload.ErrorExit = trap_http_500
411
412 maxtries = 5
413 for retry in xrange(maxtries):
414 try:
415 logging.debug('%s' % request_path)
416 result = self.rpc_server.Send(request_path, **kwargs)
417 # Sometimes GAE returns a HTTP 200 but with HTTP 500 as the content.
418 # How nice.
419 return result
420 except urllib2.HTTPError, e:
421 if retry >= (maxtries - 1):
422 raise
423 if e.code not in (500, 502, 503):
424 raise
425 except urllib2.URLError, e:
426 if retry >= (maxtries - 1):
427 raise
[email protected]c2f94e72013-10-10 20:47:52428 if (not 'Name or service not known' in e.reason and
429 not 'EOF occurred in violation of protocol' in e.reason):
[email protected]a01fd322012-02-27 19:11:08430 # Usually internal GAE flakiness.
431 raise
[email protected]36bc3842014-02-25 22:36:13432 except ssl.SSLError, e:
433 if retry >= (maxtries - 1):
434 raise
[email protected]04bd6b12014-02-26 01:04:04435 if not 'timed out' in str(e):
[email protected]36bc3842014-02-25 22:36:13436 raise
[email protected]a01fd322012-02-27 19:11:08437 # If reaching this line, loop again. Uses a small backoff.
438 time.sleep(1+maxtries*2)
439 finally:
440 upload.ErrorExit = old_error_exit
[email protected]cab38e92011-04-09 00:30:51441
442 # DEPRECATED.
443 Send = get
[email protected]4bac4b52012-11-27 20:33:52444
445
[email protected]99798242014-03-26 18:44:43446class OAuthRpcServer(object):
447 def __init__(self,
448 host,
[email protected]92c30092014-04-15 00:30:37449 client_email,
[email protected]99798242014-03-26 18:44:43450 client_private_key,
451 private_key_password='notasecret',
452 user_agent=None,
453 timeout=None,
454 extra_headers=None):
455 """Wrapper around httplib2.Http() that handles authentication.
456
[email protected]92c30092014-04-15 00:30:37457 client_email: email associated with the service account
[email protected]99798242014-03-26 18:44:43458 client_private_key: encrypted private key, as a string
459 private_key_password: password used to decrypt the private key
460 """
461
462 # Enforce https
463 host_parts = urlparse.urlparse(host)
464
465 if host_parts.scheme == 'https': # fine
466 self.host = host
467 elif host_parts.scheme == 'http':
468 upload.logging.warning('Changing protocol to https')
469 self.host = 'https' + host[4:]
470 else:
471 msg = 'Invalid url provided: %s' % host
472 upload.logging.error(msg)
473 raise ValueError(msg)
474
475 self.host = self.host.rstrip('/')
476
477 self.extra_headers = extra_headers or {}
478
479 if not oa2client.HAS_OPENSSL:
[email protected]92c30092014-04-15 00:30:37480 logging.error("No support for OpenSSL has been found, "
[email protected]99798242014-03-26 18:44:43481 "OAuth2 support requires it.")
482 logging.error("Installing pyopenssl will probably solve this issue.")
483 raise RuntimeError('No OpenSSL support')
[email protected]3fd55f92014-04-18 22:17:21484 self.creds = oa2client.SignedJwtAssertionCredentials(
[email protected]92c30092014-04-15 00:30:37485 client_email,
[email protected]99798242014-03-26 18:44:43486 client_private_key,
487 'https://www.googleapis.com/auth/userinfo.email',
488 private_key_password=private_key_password,
489 user_agent=user_agent)
490
[email protected]3fd55f92014-04-18 22:17:21491 self._http = self.creds.authorize(httplib2.Http(timeout=timeout))
[email protected]99798242014-03-26 18:44:43492
493 def Send(self,
494 request_path,
495 payload=None,
496 content_type='application/octet-stream',
497 timeout=None,
498 extra_headers=None,
499 **kwargs):
500 """Send a POST or GET request to the server.
501
502 Args:
503 request_path: path on the server to hit. This is concatenated with the
504 value of 'host' provided to the constructor.
505 payload: request is a POST if not None, GET otherwise
506 timeout: in seconds
507 extra_headers: (dict)
508 """
509 # This method signature should match upload.py:AbstractRpcServer.Send()
510 method = 'GET'
511
512 headers = self.extra_headers.copy()
513 headers.update(extra_headers or {})
514
515 if payload is not None:
516 method = 'POST'
517 headers['Content-Type'] = content_type
[email protected]99798242014-03-26 18:44:43518
519 prev_timeout = self._http.timeout
520 try:
521 if timeout:
522 self._http.timeout = timeout
523 # TODO(pgervais) implement some kind of retry mechanism (see upload.py).
524 url = self.host + request_path
525 if kwargs:
526 url += "?" + urllib.urlencode(kwargs)
527
[email protected]3fd55f92014-04-18 22:17:21528 # This weird loop is there to detect when the OAuth2 token has expired.
529 # This is specific to appengine *and* rietveld. It relies on the
530 # assumption that a 302 is triggered only by an expired OAuth2 token. This
531 # prevents any usage of redirections in pages accessed this way.
[email protected]92c30092014-04-15 00:30:37532
[email protected]3fd55f92014-04-18 22:17:21533 # This variable is used to make sure the following loop runs only twice.
534 redirect_caught = False
535 while True:
536 try:
537 ret = self._http.request(url,
538 method=method,
539 body=payload,
540 headers=headers,
541 redirections=0)
542 except httplib2.RedirectLimit:
543 if redirect_caught or method != 'GET':
544 logging.error('Redirection detected after logging in. Giving up.')
545 raise
546 redirect_caught = True
547 logging.debug('Redirection detected. Trying to log in again...')
548 self.creds.access_token = None
549 continue
550 break
551
[email protected]99798242014-03-26 18:44:43552 return ret[1]
553
554 finally:
555 self._http.timeout = prev_timeout
556
557
558class JwtOAuth2Rietveld(Rietveld):
559 """Access to Rietveld using OAuth authentication.
560
561 This class is supposed to be used only by bots, since this kind of
562 access is restricted to service accounts.
563 """
564 # The parent__init__ is not called on purpose.
565 # pylint: disable=W0231
566 def __init__(self,
567 url,
[email protected]92c30092014-04-15 00:30:37568 client_email,
[email protected]99798242014-03-26 18:44:43569 client_private_key_file,
570 private_key_password=None,
571 extra_headers=None):
[email protected]92c30092014-04-15 00:30:37572
573 # These attributes are accessed by commit queue. Keep them.
574 self.email = client_email
575 self.private_key_file = client_private_key_file
576
[email protected]99798242014-03-26 18:44:43577 if private_key_password is None: # '' means 'empty password'
578 private_key_password = 'notasecret'
579
580 self.url = url.rstrip('/')
[email protected]92c30092014-04-15 00:30:37581 bot_url = self.url + '/bots'
582
[email protected]99798242014-03-26 18:44:43583 with open(client_private_key_file, 'rb') as f:
584 client_private_key = f.read()
[email protected]92c30092014-04-15 00:30:37585 logging.info('Using OAuth login: %s' % client_email)
586 self.rpc_server = OAuthRpcServer(bot_url,
587 client_email,
[email protected]99798242014-03-26 18:44:43588 client_private_key,
589 private_key_password=private_key_password,
590 extra_headers=extra_headers or {})
591 self._xsrf_token = None
592 self._xsrf_token_time = None
593
594
[email protected]4bac4b52012-11-27 20:33:52595class CachingRietveld(Rietveld):
596 """Caches the common queries.
597
598 Not to be used in long-standing processes, like the commit queue.
599 """
600 def __init__(self, *args, **kwargs):
601 super(CachingRietveld, self).__init__(*args, **kwargs)
602 self._cache = {}
603
604 def _lookup(self, function_name, args, update):
605 """Caches the return values corresponding to the arguments.
606
607 It is important that the arguments are standardized, like None vs False.
608 """
609 function_cache = self._cache.setdefault(function_name, {})
610 if args not in function_cache:
611 function_cache[args] = update(*args)
612 return copy.deepcopy(function_cache[args])
613
614 def get_description(self, issue):
615 return self._lookup(
616 'get_description',
617 (issue,),
618 super(CachingRietveld, self).get_description)
619
620 def get_issue_properties(self, issue, messages):
621 """Returns the issue properties.
622
623 Because in practice the presubmit checks often ask without messages first
624 and then with messages, always ask with messages and strip off if not asked
625 for the messages.
626 """
627 # It's a tad slower to request with the message but it's better than
628 # requesting the properties twice.
629 data = self._lookup(
630 'get_issue_properties',
631 (issue, True),
632 super(CachingRietveld, self).get_issue_properties)
633 if not messages:
634 # Assumes self._lookup uses deepcopy.
635 del data['messages']
636 return data
637
638 def get_patchset_properties(self, issue, patchset):
639 return self._lookup(
640 'get_patchset_properties',
641 (issue, patchset),
642 super(CachingRietveld, self).get_patchset_properties)
[email protected]c7a9efa2013-09-20 19:38:39643
644
645class ReadOnlyRietveld(object):
646 """
647 Only provides read operations, and simulates writes locally.
648
649 Intentionally do not inherit from Rietveld to avoid any write-issuing
650 logic to be invoked accidentally.
651 """
652
653 # Dictionary of local changes, indexed by issue number as int.
654 _local_changes = {}
655
656 def __init__(self, *args, **kwargs):
657 # We still need an actual Rietveld instance to issue reads, just keep
658 # it hidden.
659 self._rietveld = Rietveld(*args, **kwargs)
660
661 @classmethod
662 def _get_local_changes(cls, issue):
663 """Returns dictionary of local changes for |issue|, if any."""
664 return cls._local_changes.get(issue, {})
665
666 @property
667 def url(self):
668 return self._rietveld.url
669
670 @property
671 def email(self):
672 return self._rietveld.email
673
[email protected]6c3c3262014-03-26 17:08:51674 @property
675 def password(self):
676 return self._rietveld.password
677
[email protected]c7a9efa2013-09-20 19:38:39678 def get_pending_issues(self):
679 pending_issues = self._rietveld.get_pending_issues()
680
681 # Filter out issues we've closed or unchecked the commit checkbox.
682 return [issue for issue in pending_issues
683 if not self._get_local_changes(issue).get('closed', False) and
684 self._get_local_changes(issue).get('commit', True)]
685
686 def close_issue(self, issue): # pylint:disable=R0201
687 logging.info('ReadOnlyRietveld: closing issue %d' % issue)
688 ReadOnlyRietveld._local_changes.setdefault(issue, {})['closed'] = True
689
690 def get_issue_properties(self, issue, messages):
691 data = self._rietveld.get_issue_properties(issue, messages)
692 data.update(self._get_local_changes(issue))
693 return data
694
695 def get_patchset_properties(self, issue, patchset):
696 return self._rietveld.get_patchset_properties(issue, patchset)
697
698 def get_patch(self, issue, patchset):
699 return self._rietveld.get_patch(issue, patchset)
700
701 def update_description(self, issue, description): # pylint:disable=R0201
702 logging.info('ReadOnlyRietveld: new description for issue %d: %s' %
703 (issue, description))
704
705 def add_comment(self, # pylint:disable=R0201
706 issue,
707 message,
708 add_as_reviewer=False):
709 logging.info('ReadOnlyRietveld: posting comment "%s" to issue %d' %
710 (message, issue))
711
712 def set_flag(self, issue, patchset, flag, value): # pylint:disable=R0201
713 logging.info('ReadOnlyRietveld: setting flag "%s" to "%s" for issue %d' %
714 (flag, value, issue))
715 ReadOnlyRietveld._local_changes.setdefault(issue, {})[flag] = value
716
717 def trigger_try_jobs( # pylint:disable=R0201
[email protected]58a69cb2014-03-01 02:08:29718 self, issue, patchset, reason, clobber, revision, builders_and_tests,
719 master=None):
[email protected]c7a9efa2013-09-20 19:38:39720 logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
721 (builders_and_tests, issue))
[email protected]58a69cb2014-03-01 02:08:29722
723 def trigger_distributed_try_jobs( # pylint:disable=R0201
724 self, issue, patchset, reason, clobber, revision, masters):
725 logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
726 (masters, issue))