Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(372)

Side by Side Diff: my_activity.py

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

Powered by Google App Engine
This is Rietveld 408576698