OLD | NEW |
1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
2 # Copyright (c) 2012 The Chromium Authors. All rights reserved. | 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 | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 | 5 |
6 """Get stats about your activity. | 6 """Get stats about your activity. |
7 | 7 |
8 Example: | 8 Example: |
9 - my_activity.py for stats for the current week (last week on mondays). | 9 - my_activity.py for stats for the current week (last week on mondays). |
10 - my_activity.py -Q for stats for last quarter. | 10 - my_activity.py -Q for stats for last quarter. |
(...skipping 24 matching lines...) Expand all Loading... |
35 import sys | 35 import sys |
36 import urllib | 36 import urllib |
37 import urllib2 | 37 import urllib2 |
38 | 38 |
39 import auth | 39 import auth |
40 import fix_encoding | 40 import fix_encoding |
41 import gerrit_util | 41 import gerrit_util |
42 import rietveld | 42 import rietveld |
43 from third_party import upload | 43 from third_party import upload |
44 | 44 |
| 45 import auth |
| 46 from third_party import httplib2 |
| 47 |
45 try: | 48 try: |
46 from dateutil.relativedelta import relativedelta # pylint: disable=F0401 | 49 from dateutil.relativedelta import relativedelta # pylint: disable=F0401 |
47 except ImportError: | 50 except ImportError: |
48 print 'python-dateutil package required' | 51 print 'python-dateutil package required' |
49 exit(1) | 52 exit(1) |
50 | 53 |
51 # python-keyring provides easy access to the system keyring. | 54 # python-keyring provides easy access to the system keyring. |
52 try: | 55 try: |
53 import keyring # pylint: disable=W0611,F0401 | 56 import keyring # pylint: disable=W0611,F0401 |
54 except ImportError: | 57 except ImportError: |
(...skipping 37 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
92 | 95 |
93 gerrit_instances = [ | 96 gerrit_instances = [ |
94 { | 97 { |
95 'url': 'chromium-review.googlesource.com', | 98 'url': 'chromium-review.googlesource.com', |
96 'shorturl': 'crosreview.com', | 99 'shorturl': 'crosreview.com', |
97 }, | 100 }, |
98 { | 101 { |
99 'url': 'chrome-internal-review.googlesource.com', | 102 'url': 'chrome-internal-review.googlesource.com', |
100 'shorturl': 'crosreview.com/i', | 103 'shorturl': 'crosreview.com/i', |
101 }, | 104 }, |
102 { | |
103 'host': 'gerrit.chromium.org', | |
104 'port': 29418, | |
105 }, | |
106 ] | 105 ] |
107 | 106 |
108 google_code_projects = [ | 107 google_code_projects = [ |
109 { | 108 { |
110 'name': 'brillo', | 109 'name': 'brillo', |
111 'shorturl': 'brbug.com', | 110 'shorturl': 'brbug.com', |
112 }, | 111 }, |
113 { | 112 { |
114 'name': 'chromium', | 113 'name': 'chromium', |
115 'shorturl': 'crbug.com', | 114 'shorturl': 'crbug.com', |
116 }, | 115 }, |
117 { | 116 { |
118 'name': 'chromium-os', | 117 'name': 'chromium-os', |
119 'shorturl': 'crosbug.com', | 118 'shorturl': 'crosbug.com', |
120 }, | 119 }, |
121 { | 120 { |
122 'name': 'chrome-os-partner', | 121 'name': 'chrome-os-partner', |
123 }, | 122 }, |
124 { | 123 { |
125 'name': 'google-breakpad', | 124 'name': 'google-breakpad', |
126 }, | 125 }, |
127 { | 126 { |
128 'name': 'gyp', | 127 'name': 'gyp', |
129 }, | 128 }, |
130 { | 129 { |
131 'name': 'skia', | 130 'name': 'skia', |
132 }, | 131 }, |
133 ] | 132 ] |
134 | 133 |
135 # Uses ClientLogin to authenticate the user for Google Code issue trackers. | |
136 def get_auth_token(email): | |
137 # 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) | |
140 for _ in xrange(3): | |
141 email, password = creds.GetUserCredentials() | |
142 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: | |
158 print e | |
159 | |
160 print 'Unable to authenticate to code.google.com.' | |
161 print 'Some issues may be missing.' | |
162 return None | |
163 | |
164 | |
165 def username(email): | 134 def username(email): |
166 """Keeps the username of an email address.""" | 135 """Keeps the username of an email address.""" |
167 return email and email.split('@', 1)[0] | 136 return email and email.split('@', 1)[0] |
168 | 137 |
169 | 138 |
170 def datetime_to_midnight(date): | 139 def datetime_to_midnight(date): |
171 return date - timedelta(hours=date.hour, minutes=date.minute, | 140 return date - timedelta(hours=date.hour, minutes=date.minute, |
172 seconds=date.second, microseconds=date.microsecond) | 141 seconds=date.second, microseconds=date.microsecond) |
173 | 142 |
174 | 143 |
(...skipping 48 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
223 self.user = options.user | 192 self.user = options.user |
224 self.changes = [] | 193 self.changes = [] |
225 self.reviews = [] | 194 self.reviews = [] |
226 self.issues = [] | 195 self.issues = [] |
227 self.check_cookies() | 196 self.check_cookies() |
228 self.google_code_auth_token = None | 197 self.google_code_auth_token = None |
229 | 198 |
230 # Check the codereview cookie jar to determine which Rietveld instances to | 199 # Check the codereview cookie jar to determine which Rietveld instances to |
231 # authenticate to. | 200 # authenticate to. |
232 def check_cookies(self): | 201 def check_cookies(self): |
233 cookie_file = os.path.expanduser('~/.codereview_upload_cookies') | |
234 if not os.path.exists(cookie_file): | |
235 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 = [] | |
245 | |
246 filtered_instances = [] | 202 filtered_instances = [] |
247 | 203 |
248 def has_cookie(instance): | 204 def has_cookie(instance): |
249 for cookie in cookie_jar: | 205 auth_config = auth.extract_auth_config_from_options(self.options) |
250 if cookie.name == 'SACSID' and cookie.domain == instance['url']: | 206 a = auth.get_authenticator_for_host(instance['url'], auth_config) |
251 return True | 207 return a.has_cached_credentials() |
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 | 208 |
259 for instance in rietveld_instances: | 209 for instance in rietveld_instances: |
260 instance['auth'] = has_cookie(instance) | 210 instance['auth'] = has_cookie(instance) |
261 | 211 |
262 if filtered_instances: | 212 if filtered_instances: |
263 print ('No cookie found for the following Rietveld instance%s:' % | 213 print ('No cookie found for the following Rietveld instance%s:' % |
264 ('s' if len(filtered_instances) > 1 else '')) | 214 ('s' if len(filtered_instances) > 1 else '')) |
265 for instance in filtered_instances: | 215 for instance in filtered_instances: |
266 print '\t' + instance['url'] | 216 print '\t' + instance['url'] |
267 print 'Use --auth if you would like to authenticate to them.\n' | 217 print 'Use --auth if you would like to authenticate to them.\n' |
(...skipping 186 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
454 replies = filter(lambda r: 'author' in r and 'email' in r['author'], | 404 replies = filter(lambda r: 'author' in r and 'email' in r['author'], |
455 replies) | 405 replies) |
456 for reply in replies: | 406 for reply in replies: |
457 ret.append({ | 407 ret.append({ |
458 'author': reply['author']['email'], | 408 'author': reply['author']['email'], |
459 'created': datetime_from_gerrit(reply['date']), | 409 'created': datetime_from_gerrit(reply['date']), |
460 'content': reply['message'], | 410 'content': reply['message'], |
461 }) | 411 }) |
462 return ret | 412 return ret |
463 | 413 |
464 def google_code_issue_search(self, instance): | 414 def project_hosting_issue_search(self, instance): |
465 time_format = '%Y-%m-%dT%T' | 415 auth_config = auth.extract_auth_config_from_options(self.options) |
466 # See http://code.google.com/p/support/wiki/IssueTrackerAPI | 416 authenticator = auth.get_authenticator_for_host( |
467 # q=<owner>@chromium.org does a full text search for <owner>@chromium.org. | 417 "code.google.com", auth_config) |
468 # This will accept the issue if owner is the owner or in the cc list. Might | 418 http = authenticator.authorize(httplib2.Http()) |
469 # have some false positives, though. | 419 url = "https://www.googleapis.com/projecthosting/v2/projects/%s/issues" % ( |
| 420 instance["name"]) |
| 421 epoch = datetime.utcfromtimestamp(0) |
| 422 user_str = '%s@chromium.org' % self.user |
470 | 423 |
471 # Don't filter normally on modified_before because it can filter out things | 424 query_data = urllib.urlencode({ |
472 # that were modified in the time period and then modified again after it. | 425 'maxResults': 10000, |
473 gcode_url = ('https://code.google.com/feeds/issues/p/%s/issues/full' % | 426 'q': user_str, |
474 instance['name']) | 427 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(), |
475 | 428 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(), |
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 }) | 429 }) |
483 | 430 url = url + '?' + query_data |
484 opener = urllib2.build_opener() | 431 _, body = http.request(url) |
485 if self.google_code_auth_token: | 432 content = json.loads(body) |
486 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' % | 433 if not content: |
487 self.google_code_auth_token)] | 434 print "Unable to parse %s response from projecthosting." % ( |
488 gcode_json = None | 435 instance["name"]) |
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.' | |
495 | |
496 if not gcode_json or 'entry' not in gcode_json['feed']: | |
497 return [] | 436 return [] |
498 | 437 |
499 issues = gcode_json['feed']['entry'] | 438 issues = [] |
500 issues = map(partial(self.process_google_code_issue, instance), issues) | 439 if 'items' in content: |
501 issues = filter(self.filter_issue, issues) | 440 items = content['items'] |
502 issues = sorted(issues, key=lambda i: i['modified'], reverse=True) | 441 for item in items: |
| 442 issue = { |
| 443 "header": item["title"], |
| 444 "created": item["published"], |
| 445 "modified": item["updated"], |
| 446 "author": item["author"]["name"], |
| 447 "url": "https://code.google.com/p/%s/issues/detail?id=%s" % ( |
| 448 instance["name"], item["id"]), |
| 449 "comments": [] |
| 450 } |
| 451 if 'owner' in item: |
| 452 issue['owner'] = item['owner']['name'] |
| 453 else: |
| 454 issue['owner'] = 'None' |
| 455 if issue['owner'] == user_str or issue['author'] == user_str: |
| 456 issues.append(issue) |
| 457 |
503 return issues | 458 return issues |
504 | 459 |
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'] = '' | |
511 if 'issues$owner' in issue: | |
512 ret['owner'] = issue['issues$owner']['issues$username']['$t'] | |
513 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 | |
566 def print_heading(self, heading): | 460 def print_heading(self, heading): |
567 print | 461 print |
568 print self.options.output_format_heading.format(heading=heading) | 462 print self.options.output_format_heading.format(heading=heading) |
569 | 463 |
570 def print_change(self, change): | 464 def print_change(self, change): |
571 optional_values = { | 465 optional_values = { |
572 'reviewers': ', '.join(change['reviewers']) | 466 'reviewers': ', '.join(change['reviewers']) |
573 } | 467 } |
574 self.print_generic(self.options.output_format, | 468 self.print_generic(self.options.output_format, |
575 self.options.output_format_changes, | 469 self.options.output_format_changes, |
(...skipping 65 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
641 | 535 |
642 def auth_for_changes(self): | 536 def auth_for_changes(self): |
643 #TODO(cjhopman): Move authentication check for getting changes here. | 537 #TODO(cjhopman): Move authentication check for getting changes here. |
644 pass | 538 pass |
645 | 539 |
646 def auth_for_reviews(self): | 540 def auth_for_reviews(self): |
647 # Reviews use all the same instances as changes so no authentication is | 541 # Reviews use all the same instances as changes so no authentication is |
648 # required. | 542 # required. |
649 pass | 543 pass |
650 | 544 |
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): | 545 def get_changes(self): |
656 for instance in rietveld_instances: | 546 for instance in rietveld_instances: |
657 self.changes += self.rietveld_search(instance, owner=self.user) | 547 self.changes += self.rietveld_search(instance, owner=self.user) |
658 | 548 |
659 for instance in gerrit_instances: | 549 for instance in gerrit_instances: |
660 self.changes += self.gerrit_search(instance, owner=self.user) | 550 self.changes += self.gerrit_search(instance, owner=self.user) |
661 | 551 |
662 def print_changes(self): | 552 def print_changes(self): |
663 if self.changes: | 553 if self.changes: |
664 self.print_heading('Changes') | 554 self.print_heading('Changes') |
(...skipping 10 matching lines...) Expand all Loading... |
675 self.reviews += reviews | 565 self.reviews += reviews |
676 | 566 |
677 def print_reviews(self): | 567 def print_reviews(self): |
678 if self.reviews: | 568 if self.reviews: |
679 self.print_heading('Reviews') | 569 self.print_heading('Reviews') |
680 for review in self.reviews: | 570 for review in self.reviews: |
681 self.print_review(review) | 571 self.print_review(review) |
682 | 572 |
683 def get_issues(self): | 573 def get_issues(self): |
684 for project in google_code_projects: | 574 for project in google_code_projects: |
685 self.issues += self.google_code_issue_search(project) | 575 self.issues += self.project_hosting_issue_search(project) |
686 | 576 |
687 def print_issues(self): | 577 def print_issues(self): |
688 if self.issues: | 578 if self.issues: |
689 self.print_heading('Issues') | 579 self.print_heading('Issues') |
690 for issue in self.issues: | 580 for issue in self.issues: |
691 self.print_issue(issue) | 581 self.print_issue(issue) |
692 | 582 |
693 def print_activity(self): | 583 def print_activity(self): |
694 self.print_changes() | 584 self.print_changes() |
695 self.print_reviews() | 585 self.print_reviews() |
(...skipping 138 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
834 options.changes = True | 724 options.changes = True |
835 options.issues = True | 725 options.issues = True |
836 options.reviews = True | 726 options.reviews = True |
837 | 727 |
838 # First do any required authentication so none of the user interaction has to | 728 # First do any required authentication so none of the user interaction has to |
839 # wait for actual work. | 729 # wait for actual work. |
840 if options.changes: | 730 if options.changes: |
841 my_activity.auth_for_changes() | 731 my_activity.auth_for_changes() |
842 if options.reviews: | 732 if options.reviews: |
843 my_activity.auth_for_reviews() | 733 my_activity.auth_for_reviews() |
844 if options.issues: | |
845 my_activity.auth_for_issues() | |
846 | 734 |
847 print 'Looking up activity.....' | 735 print 'Looking up activity.....' |
848 | 736 |
849 if options.changes: | 737 try: |
850 my_activity.get_changes() | 738 if options.changes: |
851 if options.reviews: | 739 my_activity.get_changes() |
852 my_activity.get_reviews() | 740 if options.reviews: |
853 if options.issues: | 741 my_activity.get_reviews() |
854 my_activity.get_issues() | 742 if options.issues: |
| 743 my_activity.get_issues() |
| 744 except auth.AuthenticationError as e: |
| 745 print "auth.AuthenticationError: %s" % e |
855 | 746 |
856 print '\n\n\n' | 747 print '\n\n\n' |
857 | 748 |
858 my_activity.print_changes() | 749 my_activity.print_changes() |
859 my_activity.print_reviews() | 750 my_activity.print_reviews() |
860 my_activity.print_issues() | 751 my_activity.print_issues() |
861 return 0 | 752 return 0 |
862 | 753 |
863 | 754 |
864 if __name__ == '__main__': | 755 if __name__ == '__main__': |
865 # Fix encoding to support non-ascii issue titles. | 756 # Fix encoding to support non-ascii issue titles. |
866 fix_encoding.fix_encoding() | 757 fix_encoding.fix_encoding() |
867 | 758 |
868 try: | 759 try: |
869 sys.exit(main()) | 760 sys.exit(main()) |
870 except KeyboardInterrupt: | 761 except KeyboardInterrupt: |
871 sys.stderr.write('interrupted\n') | 762 sys.stderr.write('interrupted\n') |
872 sys.exit(1) | 763 sys.exit(1) |
OLD | NEW |