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