blob: 022489b51605b0febdeaf1b5dce35f25a396b795 [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
16# These services typically only provide a created time and a last modified time
17# for each item for general queries. This is not enough to determine if there
18# was activity in a given time period. So, we first query for all things created
19# before end and modified after begin. Then, we get the details of each item and
20# check those details to determine if there was activity in the given period.
21# This means that query time scales mostly with (today() - begin).
22
23import cookielib
24import datetime
25from datetime import datetime
26from datetime import timedelta
27from functools import partial
28import json
29import optparse
30import os
31import subprocess
32import sys
33import urllib
34import urllib2
35
36import rietveld
37from third_party import upload
38
39try:
40 from dateutil.relativedelta import relativedelta # pylint: disable=F0401
41except ImportError:
42 print 'python-dateutil package required'
43 exit(1)
44
45# python-keyring provides easy access to the system keyring.
46try:
47 import keyring # pylint: disable=W0611,F0401
48except ImportError:
49 print 'Consider installing python-keyring'
50
51
52rietveld_instances = [
53 {
54 'url': 'codereview.chromium.org',
55 'shorturl': 'crrev.com',
56 'supports_owner_modified_query': True,
57 'requires_auth': False,
58 'email_domain': 'chromium.org',
59 },
60 {
61 'url': 'chromereviews.googleplex.com',
62 'shorturl': 'go/chromerev',
63 'supports_owner_modified_query': True,
64 'requires_auth': True,
65 'email_domain': 'google.com',
66 },
67 {
68 'url': 'codereview.appspot.com',
69 'supports_owner_modified_query': True,
70 'requires_auth': False,
71 'email_domain': 'chromium.org',
72 },
73 {
74 'url': 'breakpad.appspot.com',
75 'supports_owner_modified_query': False,
76 'requires_auth': False,
77 'email_domain': 'chromium.org',
78 },
79]
80
81gerrit_instances = [
82 {
83 'url': 'gerrit.chromium.org',
84 'port': 29418,
85 },
86 {
87 'url': 'gerrit-int.chromium.org',
88 'port': 29419,
89 },
90]
91
92google_code_projects = [
93 {
94 'name': 'chromium',
95 'shorturl': 'crbug.com',
96 },
97 {
98 'name': 'chromium-os',
99 },
100 {
101 'name': 'chrome-os-partner',
102 },
103 {
104 'name': 'google-breakpad',
105 },
106 {
107 'name': 'gyp',
108 }
109]
110
111
112# Uses ClientLogin to authenticate the user for Google Code issue trackers.
113def get_auth_token(email):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000114 # KeyringCreds will use the system keyring on the first try, and prompt for
115 # a password on the next ones.
116 creds = upload.KeyringCreds('code.google.com', 'code.google.com', email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000117 for _ in xrange(3):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000118 email, password = creds.GetUserCredentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000119 url = 'https://www.google.com/accounts/ClientLogin'
120 data = urllib.urlencode({
121 'Email': email,
122 'Passwd': password,
123 'service': 'code',
124 'source': 'chrome-my-activity',
125 'accountType': 'GOOGLE',
126 })
127 req = urllib2.Request(url, data=data, headers={'Accept': 'text/plain'})
128 try:
129 response = urllib2.urlopen(req)
130 response_body = response.read()
131 response_dict = dict(x.split('=')
132 for x in response_body.split('\n') if x)
133 return response_dict['Auth']
134 except urllib2.HTTPError, e:
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000135 print e
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000136
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000137 print 'Unable to authenticate to code.google.com.'
138 print 'Some issues may be missing.'
139 return None
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000140
141
142def username(email):
143 """Keeps the username of an email address."""
144 return email and email.split('@', 1)[0]
145
146
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000147def datetime_to_midnight(date):
148 return date - timedelta(hours=date.hour, minutes=date.minute,
149 seconds=date.second, microseconds=date.microsecond)
150
151
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000152def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000153 begin = (datetime_to_midnight(date) -
154 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000155 return begin, begin + relativedelta(months=3)
156
157
158def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000159 begin = (datetime_to_midnight(date) -
160 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000161 return begin, begin + relativedelta(years=1)
162
163
164def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000165 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000166 return begin, begin + timedelta(days=7)
167
168
169def get_yes_or_no(msg):
170 while True:
171 response = raw_input(msg + ' yes/no [no] ')
172 if response == 'y' or response == 'yes':
173 return True
174 elif not response or response == 'n' or response == 'no':
175 return False
176
177
178def datetime_from_rietveld(date_string):
179 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
180
181
182def datetime_from_google_code(date_string):
183 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
184
185
186class MyActivity(object):
187 def __init__(self, options):
188 self.options = options
189 self.modified_after = options.begin
190 self.modified_before = options.end
191 self.user = options.user
192 self.changes = []
193 self.reviews = []
194 self.issues = []
195 self.check_cookies()
196 self.google_code_auth_token = None
197
198 # Check the codereview cookie jar to determine which Rietveld instances to
199 # authenticate to.
200 def check_cookies(self):
201 cookie_file = os.path.expanduser('~/.codereview_upload_cookies')
202 cookie_jar = cookielib.MozillaCookieJar(cookie_file)
203 if not os.path.exists(cookie_file):
204 exit(1)
205
206 try:
207 cookie_jar.load()
208 print 'Found cookie file: %s' % cookie_file
209 except (cookielib.LoadError, IOError):
210 exit(1)
211
212 filtered_instances = []
213
214 def has_cookie(instance):
215 for cookie in cookie_jar:
216 if cookie.name == 'SACSID' and cookie.domain == instance['url']:
217 return True
218 if self.options.auth:
219 return get_yes_or_no('No cookie found for %s. Authorize for this '
220 'instance? (may require application-specific '
221 'password)' % instance['url'])
222 filtered_instances.append(instance)
223 return False
224
225 for instance in rietveld_instances:
226 instance['auth'] = has_cookie(instance)
227
228 if filtered_instances:
229 print ('No cookie found for the following Rietveld instance%s:' %
230 ('s' if len(filtered_instances) > 1 else ''))
231 for instance in filtered_instances:
232 print '\t' + instance['url']
233 print 'Use --auth if you would like to authenticate to them.\n'
234
235 def rietveld_search(self, instance, owner=None, reviewer=None):
236 if instance['requires_auth'] and not instance['auth']:
237 return []
238
239
240 email = None if instance['auth'] else ''
241 remote = rietveld.Rietveld('https://' + instance['url'], email, None)
242
243 # See def search() in rietveld.py to see all the filters you can use.
244 query_modified_after = None
245
246 if instance['supports_owner_modified_query']:
247 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
248
249 # Rietveld does not allow search by both created_before and modified_after.
250 # (And some instances don't allow search by both owner and modified_after)
251 owner_email = None
252 reviewer_email = None
253 if owner:
254 owner_email = owner + '@' + instance['email_domain']
255 if reviewer:
256 reviewer_email = reviewer + '@' + instance['email_domain']
257 issues = remote.search(
258 owner=owner_email,
259 reviewer=reviewer_email,
260 modified_after=query_modified_after,
261 with_messages=True)
262
263 issues = filter(
264 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
265 issues)
266 issues = filter(
267 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
268 issues)
269
270 should_filter_by_user = True
271 issues = map(partial(self.process_rietveld_issue, instance), issues)
272 issues = filter(
273 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
274 issues)
275 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
276
277 return issues
278
279 def process_rietveld_issue(self, instance, issue):
280 ret = {}
281 ret['owner'] = issue['owner_email']
282 ret['author'] = ret['owner']
283
284 ret['reviewers'] = set(username(r) for r in issue['reviewers'])
285
286 shorturl = instance['url']
287 if 'shorturl' in instance:
288 shorturl = instance['shorturl']
289
290 ret['review_url'] = 'http://%s/%d' % (shorturl, issue['issue'])
291 ret['header'] = issue['description'].split('\n')[0]
292
293 ret['modified'] = datetime_from_rietveld(issue['modified'])
294 ret['created'] = datetime_from_rietveld(issue['created'])
295 ret['replies'] = self.process_rietveld_replies(issue['messages'])
296
297 return ret
298
299 @staticmethod
300 def process_rietveld_replies(replies):
301 ret = []
302 for reply in replies:
303 r = {}
304 r['author'] = reply['sender']
305 r['created'] = datetime_from_rietveld(reply['date'])
306 r['content'] = ''
307 ret.append(r)
308 return ret
309
310 def gerrit_search(self, instance, owner=None, reviewer=None):
311 max_age = datetime.today() - self.modified_after
312 max_age = max_age.days * 24 * 3600 + max_age.seconds
313
314 # See https://review.openstack.org/Documentation/cmd-query.html
315 # Gerrit doesn't allow filtering by created time, only modified time.
316 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
317 gquery_cmd = ['ssh', '-p', str(instance['port']), instance['url'],
318 'gerrit', 'query',
319 '--format', 'JSON',
320 '--comments',
321 '--',
322 '-age:%ss' % str(max_age),
323 user_filter]
324 [stdout, _] = subprocess.Popen(gquery_cmd, stdout=subprocess.PIPE,
325 stderr=subprocess.PIPE).communicate()
326 issues = str(stdout).split('\n')[:-2]
327 issues = map(json.loads, issues)
328
329 # TODO(cjhopman): should we filter abandoned changes?
330 issues = map(self.process_gerrit_issue, issues)
331 issues = filter(self.filter_issue, issues)
332 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
333
334 return issues
335
336 def process_gerrit_issue(self, issue):
337 ret = {}
338 ret['review_url'] = issue['url']
339 ret['header'] = issue['subject']
340 ret['owner'] = issue['owner']['email']
341 ret['author'] = ret['owner']
342 ret['created'] = datetime.fromtimestamp(issue['createdOn'])
343 ret['modified'] = datetime.fromtimestamp(issue['lastUpdated'])
344 if 'comments' in issue:
345 ret['replies'] = self.process_gerrit_issue_replies(issue['comments'])
346 else:
347 ret['replies'] = []
348 return ret
349
350 @staticmethod
351 def process_gerrit_issue_replies(replies):
352 ret = []
353 replies = filter(lambda r: 'email' in r['reviewer'], replies)
354 for reply in replies:
355 r = {}
356 r['author'] = reply['reviewer']['email']
357 r['created'] = datetime.fromtimestamp(reply['timestamp'])
358 r['content'] = ''
359 ret.append(r)
360 return ret
361
362 def google_code_issue_search(self, instance):
363 time_format = '%Y-%m-%dT%T'
364 # See http://code.google.com/p/support/wiki/IssueTrackerAPI
365 # q=<owner>@chromium.org does a full text search for <owner>@chromium.org.
366 # This will accept the issue if owner is the owner or in the cc list. Might
367 # have some false positives, though.
368
369 # Don't filter normally on modified_before because it can filter out things
370 # that were modified in the time period and then modified again after it.
371 gcode_url = ('https://code.google.com/feeds/issues/p/%s/issues/full' %
372 instance['name'])
373
374 gcode_data = urllib.urlencode({
375 'alt': 'json',
376 'max-results': '100000',
377 'q': '%s' % self.user,
378 'published-max': self.modified_before.strftime(time_format),
379 'updated-min': self.modified_after.strftime(time_format),
380 })
381
382 opener = urllib2.build_opener()
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000383 if self.google_code_auth_token:
384 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
385 self.google_code_auth_token)]
386 gcode_json = None
387 try:
388 gcode_get = opener.open(gcode_url + '?' + gcode_data)
389 gcode_json = json.load(gcode_get)
390 gcode_get.close()
391 except urllib2.HTTPError, _:
392 print 'Unable to access ' + instance['name'] + ' issue tracker.'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000393
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000394 if not gcode_json or 'entry' not in gcode_json['feed']:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000395 return []
396
397 issues = gcode_json['feed']['entry']
398 issues = map(partial(self.process_google_code_issue, instance), issues)
399 issues = filter(self.filter_issue, issues)
400 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
401 return issues
402
403 def process_google_code_issue(self, project, issue):
404 ret = {}
405 ret['created'] = datetime_from_google_code(issue['published']['$t'])
406 ret['modified'] = datetime_from_google_code(issue['updated']['$t'])
407
408 ret['owner'] = ''
409 if 'issues:owner' in issue:
410 ret['owner'] = issue['issues:owner'][0]['issues:username'][0]['$t']
411 ret['author'] = issue['author'][0]['name']['$t']
412
413 if 'shorturl' in project:
414 issue_id = issue['id']['$t']
415 issue_id = issue_id[issue_id.rfind('/') + 1:]
416 ret['url'] = 'http://%s/%d' % (project['shorturl'], int(issue_id))
417 else:
418 issue_url = issue['link'][1]
419 if issue_url['rel'] != 'alternate':
420 raise RuntimeError
421 ret['url'] = issue_url['href']
422 ret['header'] = issue['title']['$t']
423
424 ret['replies'] = self.get_google_code_issue_replies(issue)
425 return ret
426
427 def get_google_code_issue_replies(self, issue):
428 """Get all the comments on the issue."""
429 replies_url = issue['link'][0]
430 if replies_url['rel'] != 'replies':
431 raise RuntimeError
432
433 replies_data = urllib.urlencode({
434 'alt': 'json',
435 'fields': 'entry(published,author,content)',
436 })
437
438 opener = urllib2.build_opener()
439 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
440 self.google_code_auth_token)]
441 try:
442 replies_get = opener.open(replies_url['href'] + '?' + replies_data)
443 except urllib2.HTTPError, _:
444 return []
445
446 replies_json = json.load(replies_get)
447 replies_get.close()
448 return self.process_google_code_issue_replies(replies_json)
449
450 @staticmethod
451 def process_google_code_issue_replies(replies):
452 if 'entry' not in replies['feed']:
453 return []
454
455 ret = []
456 for entry in replies['feed']['entry']:
457 e = {}
458 e['created'] = datetime_from_google_code(entry['published']['$t'])
459 e['content'] = entry['content']['$t']
460 e['author'] = entry['author'][0]['name']['$t']
461 ret.append(e)
462 return ret
463
464 @staticmethod
465 def print_change(change):
466 print '%s %s' % (
467 change['review_url'],
468 change['header'],
469 )
470
471 @staticmethod
472 def print_issue(issue):
473 print '%s %s' % (
474 issue['url'],
475 issue['header'],
476 )
477
478 def filter_issue(self, issue, should_filter_by_user=True):
479 def maybe_filter_username(email):
480 return not should_filter_by_user or username(email) == self.user
481 if (maybe_filter_username(issue['author']) and
482 self.filter_modified(issue['created'])):
483 return True
484 if (maybe_filter_username(issue['owner']) and
485 (self.filter_modified(issue['created']) or
486 self.filter_modified(issue['modified']))):
487 return True
488 for reply in issue['replies']:
489 if self.filter_modified(reply['created']):
490 if not should_filter_by_user:
491 break
492 if (username(reply['author']) == self.user
493 or (self.user + '@') in reply['content']):
494 break
495 else:
496 return False
497 return True
498
499 def filter_modified(self, modified):
500 return self.modified_after < modified and modified < self.modified_before
501
502 def auth_for_changes(self):
503 #TODO(cjhopman): Move authentication check for getting changes here.
504 pass
505
506 def auth_for_reviews(self):
507 # Reviews use all the same instances as changes so no authentication is
508 # required.
509 pass
510
511 def auth_for_issues(self):
512 self.google_code_auth_token = (
513 get_auth_token(self.options.local_user + '@chromium.org'))
514
515 def get_changes(self):
516 for instance in rietveld_instances:
517 self.changes += self.rietveld_search(instance, owner=self.user)
518
519 for instance in gerrit_instances:
520 self.changes += self.gerrit_search(instance, owner=self.user)
521
522 def print_changes(self):
523 if self.changes:
524 print '\nChanges:'
525 for change in self.changes:
526 self.print_change(change)
527
528 def get_reviews(self):
529 for instance in rietveld_instances:
530 self.reviews += self.rietveld_search(instance, reviewer=self.user)
531
532 for instance in gerrit_instances:
533 reviews = self.gerrit_search(instance, reviewer=self.user)
534 reviews = filter(lambda r: not username(r['owner']) == self.user, reviews)
535 self.reviews += reviews
536
537 def print_reviews(self):
538 if self.reviews:
539 print '\nReviews:'
540 for review in self.reviews:
541 self.print_change(review)
542
543 def get_issues(self):
544 for project in google_code_projects:
545 self.issues += self.google_code_issue_search(project)
546
547 def print_issues(self):
548 if self.issues:
549 print '\nIssues:'
550 for c in self.issues:
551 self.print_issue(c)
552
553 def print_activity(self):
554 self.print_changes()
555 self.print_reviews()
556 self.print_issues()
557
558
559def main():
560 # Silence upload.py.
561 rietveld.upload.verbosity = 0
562
563 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
564 parser.add_option(
565 '-u', '--user', metavar='<email>',
566 default=os.environ.get('USER'),
567 help='Filter on user, default=%default')
568 parser.add_option(
569 '-b', '--begin', metavar='<date>',
570 help='Filter issues created after the date')
571 parser.add_option(
572 '-e', '--end', metavar='<date>',
573 help='Filter issues created before the date')
574 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
575 relativedelta(months=2))
576 parser.add_option(
577 '-Q', '--last_quarter', action='store_true',
578 help='Use last quarter\'s dates, e.g. %s to %s' % (
579 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
580 parser.add_option(
581 '-Y', '--this_year', action='store_true',
582 help='Use this year\'s dates')
583 parser.add_option(
584 '-w', '--week_of', metavar='<date>',
585 help='Show issues for week of the date')
586 parser.add_option(
587 '-a', '--auth',
588 action='store_true',
589 help='Ask to authenticate for instances with no auth cookie')
590
591 group = optparse.OptionGroup(parser, 'Activity Types',
592 'By default, all activity will be looked up and '
593 'printed. If any of these are specified, only '
594 'those specified will be searched.')
595 group.add_option(
596 '-c', '--changes',
597 action='store_true',
598 help='Show changes.')
599 group.add_option(
600 '-i', '--issues',
601 action='store_true',
602 help='Show issues.')
603 group.add_option(
604 '-r', '--reviews',
605 action='store_true',
606 help='Show reviews.')
607 parser.add_option_group(group)
608
609 # Remove description formatting
610 parser.format_description = (
611 lambda _: parser.description) # pylint: disable=E1101
612
613 options, args = parser.parse_args()
614 options.local_user = os.environ.get('USER')
615 if args:
616 parser.error('Args unsupported')
617 if not options.user:
618 parser.error('USER is not set, please use -u')
619
620 options.user = username(options.user)
621
622 if not options.begin:
623 if options.last_quarter:
624 begin, end = quarter_begin, quarter_end
625 elif options.this_year:
626 begin, end = get_year_of(datetime.today())
627 elif options.week_of:
628 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
629 else:
630 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
631 else:
632 begin = datetime.strptime(options.begin, '%m/%d/%y')
633 if options.end:
634 end = datetime.strptime(options.end, '%m/%d/%y')
635 else:
636 end = datetime.today()
637 options.begin, options.end = begin, end
638
639 print 'Searching for activity by %s' % options.user
640 print 'Using range %s to %s' % (options.begin, options.end)
641
642 my_activity = MyActivity(options)
643
644 if not (options.changes or options.reviews or options.issues):
645 options.changes = True
646 options.issues = True
647 options.reviews = True
648
649 # First do any required authentication so none of the user interaction has to
650 # wait for actual work.
651 if options.changes:
652 my_activity.auth_for_changes()
653 if options.reviews:
654 my_activity.auth_for_reviews()
655 if options.issues:
656 my_activity.auth_for_issues()
657
658 print 'Looking up activity.....'
659
660 if options.changes:
661 my_activity.get_changes()
662 if options.reviews:
663 my_activity.get_reviews()
664 if options.issues:
665 my_activity.get_issues()
666
667 print '\n\n\n'
668
669 my_activity.print_changes()
670 my_activity.print_reviews()
671 my_activity.print_issues()
672 return 0
673
674
675if __name__ == '__main__':
676 sys.exit(main())