| 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 |