blob: 8468772cf978f41bdc275cdbf935e781b260e203 [file] [log] [blame]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001#!/usr/bin/env python
2# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Get stats about your activity.
7
8Example:
9 - my_activity.py for stats for the current week (last week on mondays).
10 - my_activity.py -Q for stats for last quarter.
11 - my_activity.py -Y for stats for this year.
12 - my_activity.py -b 4/5/12 for stats since 4/5/12.
13 - my_activity.py -b 4/5/12 -e 6/7/12 for stats between 4/5/12 and 6/7/12.
14"""
15
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000016# TODO(vadimsh): This script knows too much about ClientLogin and cookies. It
17# will stop to work on ~20 Apr 2015.
18
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000019# These services typically only provide a created time and a last modified time
20# for each item for general queries. This is not enough to determine if there
21# was activity in a given time period. So, we first query for all things created
22# before end and modified after begin. Then, we get the details of each item and
23# check those details to determine if there was activity in the given period.
24# This means that query time scales mostly with (today() - begin).
25
26import cookielib
27import datetime
28from datetime import datetime
29from datetime import timedelta
30from functools import partial
31import json
32import optparse
33import os
34import subprocess
35import sys
36import urllib
37import urllib2
38
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000039import auth
stevefung@chromium.org832d51e2015-05-27 12:52:51 +000040import fix_encoding
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000041import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000042import rietveld
43from third_party import upload
44
45try:
46 from dateutil.relativedelta import relativedelta # pylint: disable=F0401
47except ImportError:
48 print 'python-dateutil package required'
49 exit(1)
50
51# python-keyring provides easy access to the system keyring.
52try:
53 import keyring # pylint: disable=W0611,F0401
54except ImportError:
55 print 'Consider installing python-keyring'
56
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000057rietveld_instances = [
58 {
59 'url': 'codereview.chromium.org',
60 'shorturl': 'crrev.com',
61 'supports_owner_modified_query': True,
62 'requires_auth': False,
63 'email_domain': 'chromium.org',
64 },
65 {
66 'url': 'chromereviews.googleplex.com',
67 'shorturl': 'go/chromerev',
68 'supports_owner_modified_query': True,
69 'requires_auth': True,
70 'email_domain': 'google.com',
71 },
72 {
73 'url': 'codereview.appspot.com',
74 'supports_owner_modified_query': True,
75 'requires_auth': False,
76 'email_domain': 'chromium.org',
77 },
78 {
79 'url': 'breakpad.appspot.com',
80 'supports_owner_modified_query': False,
81 'requires_auth': False,
82 'email_domain': 'chromium.org',
83 },
kjellander@chromium.org363c10c2015-03-16 10:12:22 +000084 {
85 'url': 'webrtc-codereview.appspot.com',
86 'shorturl': 'go/rtcrev',
87 'supports_owner_modified_query': True,
88 'requires_auth': False,
89 'email_domain': 'webrtc.org',
90 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000091]
92
93gerrit_instances = [
94 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000095 'url': 'chromium-review.googlesource.com',
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +000096 'shorturl': 'crosreview.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000097 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000098 {
99 'url': 'chrome-internal-review.googlesource.com',
100 'shorturl': 'crosreview.com/i',
101 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000102 {
deymo@chromium.org6c039202013-09-12 12:28:12 +0000103 'host': 'gerrit.chromium.org',
104 'port': 29418,
105 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000106]
107
108google_code_projects = [
109 {
deymo@chromium.org69bf3ad2015-02-02 22:19:46 +0000110 'name': 'brillo',
111 'shorturl': 'brbug.com',
112 },
113 {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000114 'name': 'chromium',
115 'shorturl': 'crbug.com',
116 },
117 {
118 'name': 'chromium-os',
deymo@chromium.orgc840e212013-02-13 20:40:22 +0000119 'shorturl': 'crosbug.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000120 },
121 {
122 'name': 'chrome-os-partner',
123 },
124 {
125 'name': 'google-breakpad',
126 },
127 {
128 'name': 'gyp',
enne@chromium.orgf01fad32012-11-26 18:09:38 +0000129 },
130 {
131 'name': 'skia',
132 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000133]
134
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000135# Uses ClientLogin to authenticate the user for Google Code issue trackers.
136def get_auth_token(email):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000137 # KeyringCreds will use the system keyring on the first try, and prompt for
138 # a password on the next ones.
139 creds = upload.KeyringCreds('code.google.com', 'code.google.com', email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000140 for _ in xrange(3):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000141 email, password = creds.GetUserCredentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000142 url = 'https://www.google.com/accounts/ClientLogin'
143 data = urllib.urlencode({
144 'Email': email,
145 'Passwd': password,
146 'service': 'code',
147 'source': 'chrome-my-activity',
148 'accountType': 'GOOGLE',
149 })
150 req = urllib2.Request(url, data=data, headers={'Accept': 'text/plain'})
151 try:
152 response = urllib2.urlopen(req)
153 response_body = response.read()
154 response_dict = dict(x.split('=')
155 for x in response_body.split('\n') if x)
156 return response_dict['Auth']
157 except urllib2.HTTPError, e:
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000158 print e
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000159
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000160 print 'Unable to authenticate to code.google.com.'
161 print 'Some issues may be missing.'
162 return None
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000163
164
165def username(email):
166 """Keeps the username of an email address."""
167 return email and email.split('@', 1)[0]
168
169
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000170def datetime_to_midnight(date):
171 return date - timedelta(hours=date.hour, minutes=date.minute,
172 seconds=date.second, microseconds=date.microsecond)
173
174
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000175def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000176 begin = (datetime_to_midnight(date) -
177 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000178 return begin, begin + relativedelta(months=3)
179
180
181def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000182 begin = (datetime_to_midnight(date) -
183 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000184 return begin, begin + relativedelta(years=1)
185
186
187def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000188 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000189 return begin, begin + timedelta(days=7)
190
191
192def get_yes_or_no(msg):
193 while True:
194 response = raw_input(msg + ' yes/no [no] ')
195 if response == 'y' or response == 'yes':
196 return True
197 elif not response or response == 'n' or response == 'no':
198 return False
199
200
deymo@chromium.org6c039202013-09-12 12:28:12 +0000201def datetime_from_gerrit(date_string):
202 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
203
204
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000205def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000206 try:
207 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
208 except ValueError:
209 # Sometimes rietveld returns a value without the milliseconds part, so we
210 # attempt to parse those cases as well.
211 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000212
213
214def datetime_from_google_code(date_string):
215 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
216
217
218class MyActivity(object):
219 def __init__(self, options):
220 self.options = options
221 self.modified_after = options.begin
222 self.modified_before = options.end
223 self.user = options.user
224 self.changes = []
225 self.reviews = []
226 self.issues = []
227 self.check_cookies()
228 self.google_code_auth_token = None
229
230 # Check the codereview cookie jar to determine which Rietveld instances to
231 # authenticate to.
232 def check_cookies(self):
233 cookie_file = os.path.expanduser('~/.codereview_upload_cookies')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000234 if not os.path.exists(cookie_file):
deymo@chromium.org8aee4862013-11-13 19:33:27 +0000235 print 'No Rietveld cookie file found.'
236 cookie_jar = []
237 else:
238 cookie_jar = cookielib.MozillaCookieJar(cookie_file)
239 try:
240 cookie_jar.load()
241 print 'Found cookie file: %s' % cookie_file
242 except (cookielib.LoadError, IOError):
243 print 'Error loading Rietveld cookie file: %s' % cookie_file
244 cookie_jar = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000245
246 filtered_instances = []
247
248 def has_cookie(instance):
249 for cookie in cookie_jar:
250 if cookie.name == 'SACSID' and cookie.domain == instance['url']:
251 return True
252 if self.options.auth:
253 return get_yes_or_no('No cookie found for %s. Authorize for this '
254 'instance? (may require application-specific '
255 'password)' % instance['url'])
256 filtered_instances.append(instance)
257 return False
258
259 for instance in rietveld_instances:
260 instance['auth'] = has_cookie(instance)
261
262 if filtered_instances:
263 print ('No cookie found for the following Rietveld instance%s:' %
264 ('s' if len(filtered_instances) > 1 else ''))
265 for instance in filtered_instances:
266 print '\t' + instance['url']
267 print 'Use --auth if you would like to authenticate to them.\n'
268
269 def rietveld_search(self, instance, owner=None, reviewer=None):
270 if instance['requires_auth'] and not instance['auth']:
271 return []
272
273
274 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000275 auth_config = auth.extract_auth_config_from_options(self.options)
276 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000277
278 # See def search() in rietveld.py to see all the filters you can use.
279 query_modified_after = None
280
281 if instance['supports_owner_modified_query']:
282 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
283
284 # Rietveld does not allow search by both created_before and modified_after.
285 # (And some instances don't allow search by both owner and modified_after)
286 owner_email = None
287 reviewer_email = None
288 if owner:
289 owner_email = owner + '@' + instance['email_domain']
290 if reviewer:
291 reviewer_email = reviewer + '@' + instance['email_domain']
292 issues = remote.search(
293 owner=owner_email,
294 reviewer=reviewer_email,
295 modified_after=query_modified_after,
296 with_messages=True)
297
298 issues = filter(
299 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
300 issues)
301 issues = filter(
302 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
303 issues)
304
305 should_filter_by_user = True
306 issues = map(partial(self.process_rietveld_issue, instance), issues)
307 issues = filter(
308 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
309 issues)
310 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
311
312 return issues
313
314 def process_rietveld_issue(self, instance, issue):
315 ret = {}
316 ret['owner'] = issue['owner_email']
317 ret['author'] = ret['owner']
318
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000319 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000320
321 shorturl = instance['url']
322 if 'shorturl' in instance:
323 shorturl = instance['shorturl']
324
325 ret['review_url'] = 'http://%s/%d' % (shorturl, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000326
327 # Rietveld sometimes has '\r\n' instead of '\n'.
328 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000329
330 ret['modified'] = datetime_from_rietveld(issue['modified'])
331 ret['created'] = datetime_from_rietveld(issue['created'])
332 ret['replies'] = self.process_rietveld_replies(issue['messages'])
333
334 return ret
335
336 @staticmethod
337 def process_rietveld_replies(replies):
338 ret = []
339 for reply in replies:
340 r = {}
341 r['author'] = reply['sender']
342 r['created'] = datetime_from_rietveld(reply['date'])
343 r['content'] = ''
344 ret.append(r)
345 return ret
346
deymo@chromium.org6c039202013-09-12 12:28:12 +0000347 @staticmethod
348 def gerrit_changes_over_ssh(instance, filters):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000349 # See https://review.openstack.org/Documentation/cmd-query.html
350 # Gerrit doesn't allow filtering by created time, only modified time.
deymo@chromium.org6c039202013-09-12 12:28:12 +0000351 gquery_cmd = ['ssh', '-p', str(instance['port']), instance['host'],
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000352 'gerrit', 'query',
353 '--format', 'JSON',
354 '--comments',
deymo@chromium.org6c039202013-09-12 12:28:12 +0000355 '--'] + filters
356 (stdout, _) = subprocess.Popen(gquery_cmd, stdout=subprocess.PIPE,
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000357 stderr=subprocess.PIPE).communicate()
deymo@chromium.org6c039202013-09-12 12:28:12 +0000358 # Drop the last line of the output with the stats.
359 issues = stdout.splitlines()[:-1]
360 return map(json.loads, issues)
361
362 @staticmethod
363 def gerrit_changes_over_rest(instance, filters):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000364 # Convert the "key:value" filter to a dictionary.
365 req = dict(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000366 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000367 # Instantiate the generator to force all the requests now and catch the
368 # errors here.
369 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
370 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS']))
371 except gerrit_util.GerritError, e:
372 print 'ERROR: Looking up %r: %s' % (instance['url'], e)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000373 return []
374
deymo@chromium.org6c039202013-09-12 12:28:12 +0000375 def gerrit_search(self, instance, owner=None, reviewer=None):
376 max_age = datetime.today() - self.modified_after
377 max_age = max_age.days * 24 * 3600 + max_age.seconds
378 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
379 filters = ['-age:%ss' % max_age, user_filter]
380
381 # Determine the gerrit interface to use: SSH or REST API:
382 if 'host' in instance:
383 issues = self.gerrit_changes_over_ssh(instance, filters)
384 issues = [self.process_gerrit_ssh_issue(instance, issue)
385 for issue in issues]
386 elif 'url' in instance:
387 issues = self.gerrit_changes_over_rest(instance, filters)
388 issues = [self.process_gerrit_rest_issue(instance, issue)
389 for issue in issues]
390 else:
391 raise Exception('Invalid gerrit_instances configuration.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000392
393 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000394 issues = filter(self.filter_issue, issues)
395 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
396
397 return issues
398
deymo@chromium.org6c039202013-09-12 12:28:12 +0000399 def process_gerrit_ssh_issue(self, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000400 ret = {}
401 ret['review_url'] = issue['url']
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +0000402 if 'shorturl' in instance:
403 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
404 issue['number'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000405 ret['header'] = issue['subject']
406 ret['owner'] = issue['owner']['email']
407 ret['author'] = ret['owner']
408 ret['created'] = datetime.fromtimestamp(issue['createdOn'])
409 ret['modified'] = datetime.fromtimestamp(issue['lastUpdated'])
410 if 'comments' in issue:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000411 ret['replies'] = self.process_gerrit_ssh_issue_replies(issue['comments'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000412 else:
413 ret['replies'] = []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000414 ret['reviewers'] = set(r['author'] for r in ret['replies'])
415 ret['reviewers'].discard(ret['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000416 return ret
417
418 @staticmethod
deymo@chromium.org6c039202013-09-12 12:28:12 +0000419 def process_gerrit_ssh_issue_replies(replies):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000420 ret = []
421 replies = filter(lambda r: 'email' in r['reviewer'], replies)
422 for reply in replies:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000423 ret.append({
424 'author': reply['reviewer']['email'],
425 'created': datetime.fromtimestamp(reply['timestamp']),
426 'content': '',
427 })
428 return ret
429
430 def process_gerrit_rest_issue(self, instance, issue):
431 ret = {}
432 ret['review_url'] = 'https://%s/%s' % (instance['url'], issue['_number'])
433 if 'shorturl' in instance:
434 # TODO(deymo): Move this short link to https once crosreview.com supports
435 # it.
436 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
437 issue['_number'])
438 ret['header'] = issue['subject']
439 ret['owner'] = issue['owner']['email']
440 ret['author'] = ret['owner']
441 ret['created'] = datetime_from_gerrit(issue['created'])
442 ret['modified'] = datetime_from_gerrit(issue['updated'])
443 if 'messages' in issue:
444 ret['replies'] = self.process_gerrit_rest_issue_replies(issue['messages'])
445 else:
446 ret['replies'] = []
447 ret['reviewers'] = set(r['author'] for r in ret['replies'])
448 ret['reviewers'].discard(ret['author'])
449 return ret
450
451 @staticmethod
452 def process_gerrit_rest_issue_replies(replies):
453 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000454 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
455 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000456 for reply in replies:
457 ret.append({
458 'author': reply['author']['email'],
459 'created': datetime_from_gerrit(reply['date']),
460 'content': reply['message'],
461 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000462 return ret
463
464 def google_code_issue_search(self, instance):
465 time_format = '%Y-%m-%dT%T'
466 # See http://code.google.com/p/support/wiki/IssueTrackerAPI
467 # q=<owner>@chromium.org does a full text search for <owner>@chromium.org.
468 # This will accept the issue if owner is the owner or in the cc list. Might
469 # have some false positives, though.
470
471 # Don't filter normally on modified_before because it can filter out things
472 # that were modified in the time period and then modified again after it.
473 gcode_url = ('https://code.google.com/feeds/issues/p/%s/issues/full' %
474 instance['name'])
475
476 gcode_data = urllib.urlencode({
477 'alt': 'json',
478 'max-results': '100000',
479 'q': '%s' % self.user,
480 'published-max': self.modified_before.strftime(time_format),
481 'updated-min': self.modified_after.strftime(time_format),
482 })
483
484 opener = urllib2.build_opener()
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000485 if self.google_code_auth_token:
486 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
487 self.google_code_auth_token)]
488 gcode_json = None
489 try:
490 gcode_get = opener.open(gcode_url + '?' + gcode_data)
491 gcode_json = json.load(gcode_get)
492 gcode_get.close()
493 except urllib2.HTTPError, _:
494 print 'Unable to access ' + instance['name'] + ' issue tracker.'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000495
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000496 if not gcode_json or 'entry' not in gcode_json['feed']:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000497 return []
498
499 issues = gcode_json['feed']['entry']
500 issues = map(partial(self.process_google_code_issue, instance), issues)
501 issues = filter(self.filter_issue, issues)
502 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
503 return issues
504
505 def process_google_code_issue(self, project, issue):
506 ret = {}
507 ret['created'] = datetime_from_google_code(issue['published']['$t'])
508 ret['modified'] = datetime_from_google_code(issue['updated']['$t'])
509
510 ret['owner'] = ''
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000511 if 'issues$owner' in issue:
512 ret['owner'] = issue['issues$owner']['issues$username']['$t']
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000513 ret['author'] = issue['author'][0]['name']['$t']
514
515 if 'shorturl' in project:
516 issue_id = issue['id']['$t']
517 issue_id = issue_id[issue_id.rfind('/') + 1:]
518 ret['url'] = 'http://%s/%d' % (project['shorturl'], int(issue_id))
519 else:
520 issue_url = issue['link'][1]
521 if issue_url['rel'] != 'alternate':
522 raise RuntimeError
523 ret['url'] = issue_url['href']
524 ret['header'] = issue['title']['$t']
525
526 ret['replies'] = self.get_google_code_issue_replies(issue)
527 return ret
528
529 def get_google_code_issue_replies(self, issue):
530 """Get all the comments on the issue."""
531 replies_url = issue['link'][0]
532 if replies_url['rel'] != 'replies':
533 raise RuntimeError
534
535 replies_data = urllib.urlencode({
536 'alt': 'json',
537 'fields': 'entry(published,author,content)',
538 })
539
540 opener = urllib2.build_opener()
541 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
542 self.google_code_auth_token)]
543 try:
544 replies_get = opener.open(replies_url['href'] + '?' + replies_data)
545 except urllib2.HTTPError, _:
546 return []
547
548 replies_json = json.load(replies_get)
549 replies_get.close()
550 return self.process_google_code_issue_replies(replies_json)
551
552 @staticmethod
553 def process_google_code_issue_replies(replies):
554 if 'entry' not in replies['feed']:
555 return []
556
557 ret = []
558 for entry in replies['feed']['entry']:
559 e = {}
560 e['created'] = datetime_from_google_code(entry['published']['$t'])
561 e['content'] = entry['content']['$t']
562 e['author'] = entry['author'][0]['name']['$t']
563 ret.append(e)
564 return ret
565
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000566 def print_heading(self, heading):
567 print
568 print self.options.output_format_heading.format(heading=heading)
569
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000570 def print_change(self, change):
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000571 optional_values = {
572 'reviewers': ', '.join(change['reviewers'])
573 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000574 self.print_generic(self.options.output_format,
575 self.options.output_format_changes,
576 change['header'],
577 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000578 change['author'],
579 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000580
581 def print_issue(self, issue):
582 optional_values = {
583 'owner': issue['owner'],
584 }
585 self.print_generic(self.options.output_format,
586 self.options.output_format_issues,
587 issue['header'],
588 issue['url'],
589 issue['author'],
590 optional_values)
591
592 def print_review(self, review):
593 self.print_generic(self.options.output_format,
594 self.options.output_format_reviews,
595 review['header'],
596 review['review_url'],
597 review['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000598
599 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000600 def print_generic(default_fmt, specific_fmt,
601 title, url, author,
602 optional_values=None):
603 output_format = specific_fmt if specific_fmt is not None else default_fmt
604 output_format = unicode(output_format)
605 required_values = {
606 'title': title,
607 'url': url,
608 'author': author,
609 }
610 # Merge required and optional values.
611 if optional_values is not None:
612 values = dict(required_values.items() + optional_values.items())
613 else:
614 values = required_values
stevefung@chromium.org832d51e2015-05-27 12:52:51 +0000615 print output_format.format(**values).encode(sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000616
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000617
618 def filter_issue(self, issue, should_filter_by_user=True):
619 def maybe_filter_username(email):
620 return not should_filter_by_user or username(email) == self.user
621 if (maybe_filter_username(issue['author']) and
622 self.filter_modified(issue['created'])):
623 return True
624 if (maybe_filter_username(issue['owner']) and
625 (self.filter_modified(issue['created']) or
626 self.filter_modified(issue['modified']))):
627 return True
628 for reply in issue['replies']:
629 if self.filter_modified(reply['created']):
630 if not should_filter_by_user:
631 break
632 if (username(reply['author']) == self.user
633 or (self.user + '@') in reply['content']):
634 break
635 else:
636 return False
637 return True
638
639 def filter_modified(self, modified):
640 return self.modified_after < modified and modified < self.modified_before
641
642 def auth_for_changes(self):
643 #TODO(cjhopman): Move authentication check for getting changes here.
644 pass
645
646 def auth_for_reviews(self):
647 # Reviews use all the same instances as changes so no authentication is
648 # required.
649 pass
650
651 def auth_for_issues(self):
652 self.google_code_auth_token = (
653 get_auth_token(self.options.local_user + '@chromium.org'))
654
655 def get_changes(self):
656 for instance in rietveld_instances:
657 self.changes += self.rietveld_search(instance, owner=self.user)
658
659 for instance in gerrit_instances:
660 self.changes += self.gerrit_search(instance, owner=self.user)
661
662 def print_changes(self):
663 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000664 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000665 for change in self.changes:
666 self.print_change(change)
667
668 def get_reviews(self):
669 for instance in rietveld_instances:
670 self.reviews += self.rietveld_search(instance, reviewer=self.user)
671
672 for instance in gerrit_instances:
673 reviews = self.gerrit_search(instance, reviewer=self.user)
674 reviews = filter(lambda r: not username(r['owner']) == self.user, reviews)
675 self.reviews += reviews
676
677 def print_reviews(self):
678 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000679 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000680 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000681 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000682
683 def get_issues(self):
684 for project in google_code_projects:
685 self.issues += self.google_code_issue_search(project)
686
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000687 def print_issues(self):
688 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000689 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000690 for issue in self.issues:
691 self.print_issue(issue)
692
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000693 def print_activity(self):
694 self.print_changes()
695 self.print_reviews()
696 self.print_issues()
697
698
699def main():
700 # Silence upload.py.
701 rietveld.upload.verbosity = 0
702
703 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
704 parser.add_option(
705 '-u', '--user', metavar='<email>',
706 default=os.environ.get('USER'),
707 help='Filter on user, default=%default')
708 parser.add_option(
709 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000710 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000711 parser.add_option(
712 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000713 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000714 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
715 relativedelta(months=2))
716 parser.add_option(
717 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000718 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000719 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
720 parser.add_option(
721 '-Y', '--this_year', action='store_true',
722 help='Use this year\'s dates')
723 parser.add_option(
724 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000725 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000726 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000727 '-W', '--last_week', action='count',
728 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000729 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000730 '-a', '--auth',
731 action='store_true',
732 help='Ask to authenticate for instances with no auth cookie')
733
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000734 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000735 'By default, all activity will be looked up and '
736 'printed. If any of these are specified, only '
737 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000738 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000739 '-c', '--changes',
740 action='store_true',
741 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000742 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000743 '-i', '--issues',
744 action='store_true',
745 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000746 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000747 '-r', '--reviews',
748 action='store_true',
749 help='Show reviews.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000750 parser.add_option_group(activity_types_group)
751
752 output_format_group = optparse.OptionGroup(parser, 'Output Format',
753 'By default, all activity will be printed in the '
754 'following format: {url} {title}. This can be '
755 'changed for either all activity types or '
756 'individually for each activity type. The format '
757 'is defined as documented for '
758 'string.format(...). The variables available for '
759 'all activity types are url, title and author. '
760 'Format options for specific activity types will '
761 'override the generic format.')
762 output_format_group.add_option(
763 '-f', '--output-format', metavar='<format>',
764 default=u'{url} {title}',
765 help='Specifies the format to use when printing all your activity.')
766 output_format_group.add_option(
767 '--output-format-changes', metavar='<format>',
768 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000769 help='Specifies the format to use when printing changes. Supports the '
770 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000771 output_format_group.add_option(
772 '--output-format-issues', metavar='<format>',
773 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000774 help='Specifies the format to use when printing issues. Supports the '
775 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000776 output_format_group.add_option(
777 '--output-format-reviews', metavar='<format>',
778 default=None,
779 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000780 output_format_group.add_option(
781 '--output-format-heading', metavar='<format>',
782 default=u'{heading}:',
783 help='Specifies the format to use when printing headings.')
784 output_format_group.add_option(
785 '-m', '--markdown', action='store_true',
786 help='Use markdown-friendly output (overrides --output-format '
787 'and --output-format-heading)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000788 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000789 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000790
791 # Remove description formatting
792 parser.format_description = (
793 lambda _: parser.description) # pylint: disable=E1101
794
795 options, args = parser.parse_args()
796 options.local_user = os.environ.get('USER')
797 if args:
798 parser.error('Args unsupported')
799 if not options.user:
800 parser.error('USER is not set, please use -u')
801
802 options.user = username(options.user)
803
804 if not options.begin:
805 if options.last_quarter:
806 begin, end = quarter_begin, quarter_end
807 elif options.this_year:
808 begin, end = get_year_of(datetime.today())
809 elif options.week_of:
810 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000811 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000812 begin, end = (get_week_of(datetime.today() -
813 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000814 else:
815 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
816 else:
817 begin = datetime.strptime(options.begin, '%m/%d/%y')
818 if options.end:
819 end = datetime.strptime(options.end, '%m/%d/%y')
820 else:
821 end = datetime.today()
822 options.begin, options.end = begin, end
823
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000824 if options.markdown:
825 options.output_format = ' * [{title}]({url})'
826 options.output_format_heading = '### {heading} ###'
827
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000828 print 'Searching for activity by %s' % options.user
829 print 'Using range %s to %s' % (options.begin, options.end)
830
831 my_activity = MyActivity(options)
832
833 if not (options.changes or options.reviews or options.issues):
834 options.changes = True
835 options.issues = True
836 options.reviews = True
837
838 # First do any required authentication so none of the user interaction has to
839 # wait for actual work.
840 if options.changes:
841 my_activity.auth_for_changes()
842 if options.reviews:
843 my_activity.auth_for_reviews()
844 if options.issues:
845 my_activity.auth_for_issues()
846
847 print 'Looking up activity.....'
848
849 if options.changes:
850 my_activity.get_changes()
851 if options.reviews:
852 my_activity.get_reviews()
853 if options.issues:
854 my_activity.get_issues()
855
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000856 print '\n\n\n'
857
858 my_activity.print_changes()
859 my_activity.print_reviews()
860 my_activity.print_issues()
861 return 0
862
863
864if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +0000865 # Fix encoding to support non-ascii issue titles.
866 fix_encoding.fix_encoding()
867
sbc@chromium.org013731e2015-02-26 18:28:43 +0000868 try:
869 sys.exit(main())
870 except KeyboardInterrupt:
871 sys.stderr.write('interrupted\n')
872 sys.exit(1)