blob: 22168f572709de041fb17d18020c590ea2cadb44 [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
75 def get_description(self, issue):
[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
[email protected]c73e5162011-09-21 23:16:12281 def search(
282 self,
283 owner=None, reviewer=None,
284 base=None,
285 closed=None, private=None, commit=None,
286 created_before=None, created_after=None,
287 modified_before=None, modified_after=None,
288 per_request=None, keys_only=False,
289 with_messages=False):
290 """Yields search results."""
291 # These are expected to be strings.
292 string_keys = {
293 'owner': owner,
294 'reviewer': reviewer,
295 'base': base,
296 'created_before': created_before,
297 'created_after': created_after,
298 'modified_before': modified_before,
299 'modified_after': modified_after,
300 }
301 # These are either None, False or True.
302 three_state_keys = {
303 'closed': closed,
304 'private': private,
305 'commit': commit,
306 }
307
308 url = '/search?format=json'
309 # Sort the keys mainly to ease testing.
310 for key in sorted(string_keys):
311 value = string_keys[key]
312 if value:
313 url += '&%s=%s' % (key, urllib2.quote(value))
314 for key in sorted(three_state_keys):
315 value = three_state_keys[key]
316 if value is not None:
[email protected]473527c2015-11-29 20:44:08317 url += '&%s=%s' % (key, value)
[email protected]c73e5162011-09-21 23:16:12318
319 if keys_only:
320 url += '&keys_only=True'
321 if with_messages:
322 url += '&with_messages=True'
323 if per_request:
324 url += '&limit=%d' % per_request
325
326 cursor = ''
327 while True:
328 output = self.get(url + cursor)
329 if output.startswith('<'):
330 # It's an error message. Return as no result.
331 break
332 data = json.loads(output) or {}
333 if not data.get('results'):
334 break
335 for i in data['results']:
336 yield i
337 cursor = '&cursor=%s' % data['cursor']
338
[email protected]61ea42f2012-09-05 14:58:52339 def trigger_try_jobs(
[email protected]58a69cb2014-03-01 02:08:29340 self, issue, patchset, reason, clobber, revision, builders_and_tests,
[email protected]62554f92015-01-06 00:42:39341 master=None, category='cq'):
[email protected]61ea42f2012-09-05 14:58:52342 """Requests new try jobs.
343
344 |builders_and_tests| is a map of builders: [tests] to run.
[email protected]58a69cb2014-03-01 02:08:29345 |master| is the name of the try master the builders belong to.
[email protected]62554f92015-01-06 00:42:39346 |category| is used to distinguish regular jobs and experimental jobs.
[email protected]61ea42f2012-09-05 14:58:52347
348 Returns the keys of the new TryJobResult entites.
349 """
350 params = [
351 ('reason', reason),
352 ('clobber', 'True' if clobber else 'False'),
[email protected]61ea42f2012-09-05 14:58:52353 ('builders', json.dumps(builders_and_tests)),
354 ('xsrf_token', self.xsrf_token()),
[email protected]62554f92015-01-06 00:42:39355 ('category', category),
[email protected]61ea42f2012-09-05 14:58:52356 ]
[email protected]072d94b2012-09-20 19:20:08357 if revision:
358 params.append(('revision', revision))
[email protected]58a69cb2014-03-01 02:08:29359 if master:
360 # Temporarily allow empty master names for old configurations. The try
361 # job will not be associated with a master name on rietveld. This is
362 # going to be deprecated.
363 params.append(('master', master))
[email protected]61ea42f2012-09-05 14:58:52364 return self.post('/%d/try/%d' % (issue, patchset), params)
365
[email protected]58a69cb2014-03-01 02:08:29366 def trigger_distributed_try_jobs(
[email protected]62554f92015-01-06 00:42:39367 self, issue, patchset, reason, clobber, revision, masters,
368 category='cq'):
[email protected]58a69cb2014-03-01 02:08:29369 """Requests new try jobs.
370
371 |masters| is a map of masters: map of builders: [tests] to run.
[email protected]62554f92015-01-06 00:42:39372 |category| is used to distinguish regular jobs and experimental jobs.
[email protected]58a69cb2014-03-01 02:08:29373 """
374 for (master, builders_and_tests) in masters.iteritems():
375 self.trigger_try_jobs(
376 issue, patchset, reason, clobber, revision, builders_and_tests,
[email protected]62554f92015-01-06 00:42:39377 master, category)
[email protected]58a69cb2014-03-01 02:08:29378
[email protected]61ea42f2012-09-05 14:58:52379 def get_pending_try_jobs(self, cursor=None, limit=100):
380 """Retrieves the try job requests in pending state.
381
382 Returns a tuple of the list of try jobs and the cursor for the next request.
383 """
384 url = '/get_pending_try_patchsets?limit=%d' % limit
385 extra = ('&cursor=' + cursor) if cursor else ''
386 data = json.loads(self.get(url + extra))
387 return data['jobs'], data['cursor']
388
[email protected]b3727a32011-04-04 19:31:44389 def get(self, request_path, **kwargs):
[email protected]cab38e92011-04-09 00:30:51390 kwargs.setdefault('payload', None)
391 return self._send(request_path, **kwargs)
[email protected]b3727a32011-04-04 19:31:44392
393 def post(self, request_path, data, **kwargs):
394 ctype, body = upload.EncodeMultipartFormData(data, [])
395 return self._send(request_path, payload=body, content_type=ctype, **kwargs)
396
[email protected]d612e492014-08-27 14:00:41397 def _send(self, request_path, retry_on_404=False, **kwargs):
[email protected]b3727a32011-04-04 19:31:44398 """Sends a POST/GET to Rietveld. Returns the response body."""
[email protected]e0faffa2014-02-21 01:55:31399 # rpc_server.Send() assumes timeout=None by default; make sure it's set
400 # to something reasonable.
401 kwargs.setdefault('timeout', 15)
[email protected]eebd3c92013-01-07 18:34:49402 logging.debug('POSTing to %s, args %s.', request_path, kwargs)
[email protected]a01fd322012-02-27 19:11:08403 try:
404 # Sadly, upload.py calls ErrorExit() which does a sys.exit(1) on HTTP
405 # 500 in AbstractRpcServer.Send().
406 old_error_exit = upload.ErrorExit
407 def trap_http_500(msg):
408 """Converts an incorrect ErrorExit() call into a HTTPError exception."""
409 m = re.search(r'(50\d) Server Error', msg)
410 if m:
411 # Fake an HTTPError exception. Cheezy. :(
[email protected]cef72362013-10-15 22:52:59412 raise urllib2.HTTPError(
[email protected]1dda36d2016-02-11 00:28:39413 request_path, int(m.group(1)), msg, None, StringIO.StringIO())
[email protected]a01fd322012-02-27 19:11:08414 old_error_exit(msg)
415 upload.ErrorExit = trap_http_500
416
[email protected]ab8154f2015-02-19 11:29:00417 for retry in xrange(self._maxtries):
[email protected]a01fd322012-02-27 19:11:08418 try:
419 logging.debug('%s' % request_path)
[email protected]1dda36d2016-02-11 00:28:39420 return self.rpc_server.Send(request_path, **kwargs)
[email protected]a01fd322012-02-27 19:11:08421 except urllib2.HTTPError, e:
[email protected]ab8154f2015-02-19 11:29:00422 if retry >= (self._maxtries - 1):
[email protected]a01fd322012-02-27 19:11:08423 raise
[email protected]1dda36d2016-02-11 00:28:39424 flake_codes = {500, 502, 503}
[email protected]d612e492014-08-27 14:00:41425 if retry_on_404:
[email protected]1dda36d2016-02-11 00:28:39426 flake_codes.add(404)
[email protected]d612e492014-08-27 14:00:41427 if e.code not in flake_codes:
[email protected]a01fd322012-02-27 19:11:08428 raise
429 except urllib2.URLError, e:
[email protected]ab8154f2015-02-19 11:29:00430 if retry >= (self._maxtries - 1):
[email protected]a01fd322012-02-27 19:11:08431 raise
[email protected]29d5e562015-12-03 15:00:34432
433 def is_transient():
434 # The idea here is to retry if the error isn't permanent.
435 # Unfortunately, there are so many different possible errors,
436 # that we end up enumerating those that are known to us to be
437 # transient.
438 # The reason can be a string or another exception, e.g.,
439 # socket.error or whatever else.
440 reason_as_str = str(e.reason)
[email protected]1dda36d2016-02-11 00:28:39441 for retry_anyway in (
[email protected]29d5e562015-12-03 15:00:34442 'Name or service not known',
443 'EOF occurred in violation of protocol',
[email protected]1dda36d2016-02-11 00:28:39444 'timed out'):
[email protected]29d5e562015-12-03 15:00:34445 if retry_anyway in reason_as_str:
446 return True
447 return False # Assume permanent otherwise.
448 if not is_transient():
[email protected]a01fd322012-02-27 19:11:08449 raise
[email protected]f4ef3e72015-10-22 00:39:32450 except socket.error, e:
[email protected]ab8154f2015-02-19 11:29:00451 if retry >= (self._maxtries - 1):
[email protected]36bc3842014-02-25 22:36:13452 raise
[email protected]04bd6b12014-02-26 01:04:04453 if not 'timed out' in str(e):
[email protected]36bc3842014-02-25 22:36:13454 raise
[email protected]a01fd322012-02-27 19:11:08455 # If reaching this line, loop again. Uses a small backoff.
[email protected]538f6022014-06-13 17:08:01456 time.sleep(min(10, 1+retry*2))
[email protected]e4d195a2015-10-05 15:45:18457 except urllib2.HTTPError as e:
458 print 'Request to %s failed: %s' % (e.geturl(), e.read())
459 raise
[email protected]a01fd322012-02-27 19:11:08460 finally:
461 upload.ErrorExit = old_error_exit
[email protected]cab38e92011-04-09 00:30:51462
463 # DEPRECATED.
464 Send = get
[email protected]4bac4b52012-11-27 20:33:52465
466
[email protected]99798242014-03-26 18:44:43467class OAuthRpcServer(object):
468 def __init__(self,
469 host,
[email protected]92c30092014-04-15 00:30:37470 client_email,
[email protected]99798242014-03-26 18:44:43471 client_private_key,
472 private_key_password='notasecret',
473 user_agent=None,
474 timeout=None,
475 extra_headers=None):
476 """Wrapper around httplib2.Http() that handles authentication.
477
[email protected]92c30092014-04-15 00:30:37478 client_email: email associated with the service account
[email protected]99798242014-03-26 18:44:43479 client_private_key: encrypted private key, as a string
480 private_key_password: password used to decrypt the private key
481 """
482
483 # Enforce https
484 host_parts = urlparse.urlparse(host)
485
486 if host_parts.scheme == 'https': # fine
487 self.host = host
488 elif host_parts.scheme == 'http':
489 upload.logging.warning('Changing protocol to https')
490 self.host = 'https' + host[4:]
491 else:
492 msg = 'Invalid url provided: %s' % host
493 upload.logging.error(msg)
494 raise ValueError(msg)
495
496 self.host = self.host.rstrip('/')
497
498 self.extra_headers = extra_headers or {}
499
500 if not oa2client.HAS_OPENSSL:
[email protected]92c30092014-04-15 00:30:37501 logging.error("No support for OpenSSL has been found, "
[email protected]99798242014-03-26 18:44:43502 "OAuth2 support requires it.")
503 logging.error("Installing pyopenssl will probably solve this issue.")
504 raise RuntimeError('No OpenSSL support')
[email protected]3fd55f92014-04-18 22:17:21505 self.creds = oa2client.SignedJwtAssertionCredentials(
[email protected]92c30092014-04-15 00:30:37506 client_email,
[email protected]99798242014-03-26 18:44:43507 client_private_key,
508 'https://www.googleapis.com/auth/userinfo.email',
509 private_key_password=private_key_password,
510 user_agent=user_agent)
511
[email protected]3fd55f92014-04-18 22:17:21512 self._http = self.creds.authorize(httplib2.Http(timeout=timeout))
[email protected]99798242014-03-26 18:44:43513
514 def Send(self,
515 request_path,
516 payload=None,
517 content_type='application/octet-stream',
518 timeout=None,
519 extra_headers=None,
520 **kwargs):
521 """Send a POST or GET request to the server.
522
523 Args:
524 request_path: path on the server to hit. This is concatenated with the
525 value of 'host' provided to the constructor.
526 payload: request is a POST if not None, GET otherwise
527 timeout: in seconds
528 extra_headers: (dict)
[email protected]1dda36d2016-02-11 00:28:39529
530 Returns: the HTTP response body as a string
531
532 Raises:
533 urllib2.HTTPError
[email protected]99798242014-03-26 18:44:43534 """
535 # This method signature should match upload.py:AbstractRpcServer.Send()
536 method = 'GET'
537
538 headers = self.extra_headers.copy()
539 headers.update(extra_headers or {})
540
541 if payload is not None:
542 method = 'POST'
543 headers['Content-Type'] = content_type
[email protected]99798242014-03-26 18:44:43544
545 prev_timeout = self._http.timeout
546 try:
547 if timeout:
548 self._http.timeout = timeout
[email protected]99798242014-03-26 18:44:43549 url = self.host + request_path
550 if kwargs:
551 url += "?" + urllib.urlencode(kwargs)
552
[email protected]3fd55f92014-04-18 22:17:21553 # This weird loop is there to detect when the OAuth2 token has expired.
554 # This is specific to appengine *and* rietveld. It relies on the
555 # assumption that a 302 is triggered only by an expired OAuth2 token. This
556 # prevents any usage of redirections in pages accessed this way.
[email protected]92c30092014-04-15 00:30:37557
[email protected]3fd55f92014-04-18 22:17:21558 # This variable is used to make sure the following loop runs only twice.
559 redirect_caught = False
560 while True:
561 try:
562 ret = self._http.request(url,
563 method=method,
564 body=payload,
565 headers=headers,
566 redirections=0)
567 except httplib2.RedirectLimit:
568 if redirect_caught or method != 'GET':
569 logging.error('Redirection detected after logging in. Giving up.')
570 raise
571 redirect_caught = True
572 logging.debug('Redirection detected. Trying to log in again...')
573 self.creds.access_token = None
574 continue
575 break
576
[email protected]1dda36d2016-02-11 00:28:39577 if ret[0].status >= 300:
578 raise urllib2.HTTPError(
579 request_path, int(ret[0]['status']), ret[1], None,
580 StringIO.StringIO())
581
[email protected]99798242014-03-26 18:44:43582 return ret[1]
583
584 finally:
585 self._http.timeout = prev_timeout
586
587
588class JwtOAuth2Rietveld(Rietveld):
589 """Access to Rietveld using OAuth authentication.
590
591 This class is supposed to be used only by bots, since this kind of
592 access is restricted to service accounts.
593 """
594 # The parent__init__ is not called on purpose.
595 # pylint: disable=W0231
596 def __init__(self,
597 url,
[email protected]92c30092014-04-15 00:30:37598 client_email,
[email protected]99798242014-03-26 18:44:43599 client_private_key_file,
600 private_key_password=None,
[email protected]ab8154f2015-02-19 11:29:00601 extra_headers=None,
602 maxtries=None):
[email protected]92c30092014-04-15 00:30:37603
[email protected]99798242014-03-26 18:44:43604 if private_key_password is None: # '' means 'empty password'
605 private_key_password = 'notasecret'
606
607 self.url = url.rstrip('/')
[email protected]39bb4b12015-06-17 15:53:24608 bot_url = self.url
609 if self.url.endswith('googleplex.com'):
610 bot_url = self.url + '/bots'
[email protected]92c30092014-04-15 00:30:37611
[email protected]99798242014-03-26 18:44:43612 with open(client_private_key_file, 'rb') as f:
613 client_private_key = f.read()
[email protected]92c30092014-04-15 00:30:37614 logging.info('Using OAuth login: %s' % client_email)
615 self.rpc_server = OAuthRpcServer(bot_url,
616 client_email,
[email protected]99798242014-03-26 18:44:43617 client_private_key,
618 private_key_password=private_key_password,
619 extra_headers=extra_headers or {})
620 self._xsrf_token = None
621 self._xsrf_token_time = None
622
[email protected]db98b6e2015-02-19 11:42:45623 self._maxtries = maxtries or 40
[email protected]ab8154f2015-02-19 11:29:00624
[email protected]99798242014-03-26 18:44:43625
[email protected]4bac4b52012-11-27 20:33:52626class CachingRietveld(Rietveld):
627 """Caches the common queries.
628
629 Not to be used in long-standing processes, like the commit queue.
630 """
631 def __init__(self, *args, **kwargs):
632 super(CachingRietveld, self).__init__(*args, **kwargs)
633 self._cache = {}
634
635 def _lookup(self, function_name, args, update):
636 """Caches the return values corresponding to the arguments.
637
638 It is important that the arguments are standardized, like None vs False.
639 """
640 function_cache = self._cache.setdefault(function_name, {})
641 if args not in function_cache:
642 function_cache[args] = update(*args)
643 return copy.deepcopy(function_cache[args])
644
645 def get_description(self, issue):
646 return self._lookup(
647 'get_description',
648 (issue,),
649 super(CachingRietveld, self).get_description)
650
651 def get_issue_properties(self, issue, messages):
652 """Returns the issue properties.
653
654 Because in practice the presubmit checks often ask without messages first
655 and then with messages, always ask with messages and strip off if not asked
656 for the messages.
657 """
658 # It's a tad slower to request with the message but it's better than
659 # requesting the properties twice.
660 data = self._lookup(
661 'get_issue_properties',
662 (issue, True),
663 super(CachingRietveld, self).get_issue_properties)
664 if not messages:
665 # Assumes self._lookup uses deepcopy.
666 del data['messages']
667 return data
668
669 def get_patchset_properties(self, issue, patchset):
670 return self._lookup(
671 'get_patchset_properties',
672 (issue, patchset),
673 super(CachingRietveld, self).get_patchset_properties)
[email protected]c7a9efa2013-09-20 19:38:39674
675
676class ReadOnlyRietveld(object):
677 """
678 Only provides read operations, and simulates writes locally.
679
680 Intentionally do not inherit from Rietveld to avoid any write-issuing
681 logic to be invoked accidentally.
682 """
683
684 # Dictionary of local changes, indexed by issue number as int.
685 _local_changes = {}
686
687 def __init__(self, *args, **kwargs):
688 # We still need an actual Rietveld instance to issue reads, just keep
689 # it hidden.
690 self._rietveld = Rietveld(*args, **kwargs)
691
692 @classmethod
693 def _get_local_changes(cls, issue):
694 """Returns dictionary of local changes for |issue|, if any."""
695 return cls._local_changes.get(issue, {})
696
697 @property
698 def url(self):
699 return self._rietveld.url
700
[email protected]c7a9efa2013-09-20 19:38:39701 def get_pending_issues(self):
702 pending_issues = self._rietveld.get_pending_issues()
703
704 # Filter out issues we've closed or unchecked the commit checkbox.
705 return [issue for issue in pending_issues
706 if not self._get_local_changes(issue).get('closed', False) and
707 self._get_local_changes(issue).get('commit', True)]
708
709 def close_issue(self, issue): # pylint:disable=R0201
710 logging.info('ReadOnlyRietveld: closing issue %d' % issue)
711 ReadOnlyRietveld._local_changes.setdefault(issue, {})['closed'] = True
712
713 def get_issue_properties(self, issue, messages):
714 data = self._rietveld.get_issue_properties(issue, messages)
715 data.update(self._get_local_changes(issue))
716 return data
717
718 def get_patchset_properties(self, issue, patchset):
719 return self._rietveld.get_patchset_properties(issue, patchset)
720
[email protected]d91b7e32015-06-23 11:24:07721 def get_depends_on_patchset(self, issue, patchset):
722 return self._rietveld.get_depends_on_patchset(issue, patchset)
723
[email protected]c7a9efa2013-09-20 19:38:39724 def get_patch(self, issue, patchset):
725 return self._rietveld.get_patch(issue, patchset)
726
727 def update_description(self, issue, description): # pylint:disable=R0201
728 logging.info('ReadOnlyRietveld: new description for issue %d: %s' %
729 (issue, description))
730
731 def add_comment(self, # pylint:disable=R0201
732 issue,
733 message,
734 add_as_reviewer=False):
735 logging.info('ReadOnlyRietveld: posting comment "%s" to issue %d' %
736 (message, issue))
737
738 def set_flag(self, issue, patchset, flag, value): # pylint:disable=R0201
739 logging.info('ReadOnlyRietveld: setting flag "%s" to "%s" for issue %d' %
740 (flag, value, issue))
741 ReadOnlyRietveld._local_changes.setdefault(issue, {})[flag] = value
742
743 def trigger_try_jobs( # pylint:disable=R0201
[email protected]58a69cb2014-03-01 02:08:29744 self, issue, patchset, reason, clobber, revision, builders_and_tests,
[email protected]62554f92015-01-06 00:42:39745 master=None, category='cq'):
[email protected]c7a9efa2013-09-20 19:38:39746 logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
747 (builders_and_tests, issue))
[email protected]58a69cb2014-03-01 02:08:29748
749 def trigger_distributed_try_jobs( # pylint:disable=R0201
[email protected]62554f92015-01-06 00:42:39750 self, issue, patchset, reason, clobber, revision, masters,
751 category='cq'):
[email protected]58a69cb2014-03-01 02:08:29752 logging.info('ReadOnlyRietveld: triggering try jobs %r for issue %d' %
753 (masters, issue))