blob: 35ec4d87f370f75881e859cef7e37f08dd2d31ff [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
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000016# TODO(vadimsh): This script knows too much about ClientLogin and cookies. It
17# will stop to work on ~20 Apr 2015.
18
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000019# These services typically only provide a created time and a last modified time
20# for each item for general queries. This is not enough to determine if there
21# was activity in a given time period. So, we first query for all things created
22# before end and modified after begin. Then, we get the details of each item and
23# check those details to determine if there was activity in the given period.
24# This means that query time scales mostly with (today() - begin).
25
26import cookielib
27import datetime
28from datetime import datetime
29from datetime import timedelta
30from functools import partial
31import json
32import optparse
33import os
34import subprocess
35import sys
36import urllib
37import urllib2
38
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000039import auth
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000040import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000041import rietveld
42from third_party import upload
43
44try:
45 from dateutil.relativedelta import relativedelta # pylint: disable=F0401
46except ImportError:
47 print 'python-dateutil package required'
48 exit(1)
49
50# python-keyring provides easy access to the system keyring.
51try:
52 import keyring # pylint: disable=W0611,F0401
53except ImportError:
54 print 'Consider installing python-keyring'
55
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000056rietveld_instances = [
57 {
58 'url': 'codereview.chromium.org',
59 'shorturl': 'crrev.com',
60 'supports_owner_modified_query': True,
61 'requires_auth': False,
62 'email_domain': 'chromium.org',
63 },
64 {
65 'url': 'chromereviews.googleplex.com',
66 'shorturl': 'go/chromerev',
67 'supports_owner_modified_query': True,
68 'requires_auth': True,
69 'email_domain': 'google.com',
70 },
71 {
72 'url': 'codereview.appspot.com',
73 'supports_owner_modified_query': True,
74 'requires_auth': False,
75 'email_domain': 'chromium.org',
76 },
77 {
78 'url': 'breakpad.appspot.com',
79 'supports_owner_modified_query': False,
80 'requires_auth': False,
81 'email_domain': 'chromium.org',
82 },
kjellander@chromium.org363c10c2015-03-16 10:12:22 +000083 {
84 'url': 'webrtc-codereview.appspot.com',
85 'shorturl': 'go/rtcrev',
86 'supports_owner_modified_query': True,
87 'requires_auth': False,
88 'email_domain': 'webrtc.org',
89 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000090]
91
92gerrit_instances = [
93 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000094 'url': 'chromium-review.googlesource.com',
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +000095 'shorturl': 'crosreview.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000096 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000097 {
98 'url': 'chrome-internal-review.googlesource.com',
99 'shorturl': 'crosreview.com/i',
100 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000101 {
deymo@chromium.org6c039202013-09-12 12:28:12 +0000102 'host': 'gerrit.chromium.org',
103 'port': 29418,
104 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000105]
106
107google_code_projects = [
108 {
deymo@chromium.org69bf3ad2015-02-02 22:19:46 +0000109 'name': 'brillo',
110 'shorturl': 'brbug.com',
111 },
112 {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000113 'name': 'chromium',
114 'shorturl': 'crbug.com',
115 },
116 {
117 'name': 'chromium-os',
deymo@chromium.orgc840e212013-02-13 20:40:22 +0000118 'shorturl': 'crosbug.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000119 },
120 {
121 'name': 'chrome-os-partner',
122 },
123 {
124 'name': 'google-breakpad',
125 },
126 {
127 'name': 'gyp',
enne@chromium.orgf01fad32012-11-26 18:09:38 +0000128 },
129 {
130 'name': 'skia',
131 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000132]
133
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000134# Uses ClientLogin to authenticate the user for Google Code issue trackers.
135def get_auth_token(email):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000136 # KeyringCreds will use the system keyring on the first try, and prompt for
137 # a password on the next ones.
138 creds = upload.KeyringCreds('code.google.com', 'code.google.com', email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000139 for _ in xrange(3):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000140 email, password = creds.GetUserCredentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000141 url = 'https://www.google.com/accounts/ClientLogin'
142 data = urllib.urlencode({
143 'Email': email,
144 'Passwd': password,
145 'service': 'code',
146 'source': 'chrome-my-activity',
147 'accountType': 'GOOGLE',
148 })
149 req = urllib2.Request(url, data=data, headers={'Accept': 'text/plain'})
150 try:
151 response = urllib2.urlopen(req)
152 response_body = response.read()
153 response_dict = dict(x.split('=')
154 for x in response_body.split('\n') if x)
155 return response_dict['Auth']
156 except urllib2.HTTPError, e:
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000157 print e
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000158
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000159 print 'Unable to authenticate to code.google.com.'
160 print 'Some issues may be missing.'
161 return None
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000162
163
164def username(email):
165 """Keeps the username of an email address."""
166 return email and email.split('@', 1)[0]
167
168
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000169def datetime_to_midnight(date):
170 return date - timedelta(hours=date.hour, minutes=date.minute,
171 seconds=date.second, microseconds=date.microsecond)
172
173
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000174def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000175 begin = (datetime_to_midnight(date) -
176 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000177 return begin, begin + relativedelta(months=3)
178
179
180def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000181 begin = (datetime_to_midnight(date) -
182 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000183 return begin, begin + relativedelta(years=1)
184
185
186def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000187 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000188 return begin, begin + timedelta(days=7)
189
190
191def get_yes_or_no(msg):
192 while True:
193 response = raw_input(msg + ' yes/no [no] ')
194 if response == 'y' or response == 'yes':
195 return True
196 elif not response or response == 'n' or response == 'no':
197 return False
198
199
deymo@chromium.org6c039202013-09-12 12:28:12 +0000200def datetime_from_gerrit(date_string):
201 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
202
203
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000204def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000205 try:
206 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
207 except ValueError:
208 # Sometimes rietveld returns a value without the milliseconds part, so we
209 # attempt to parse those cases as well.
210 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000211
212
213def datetime_from_google_code(date_string):
214 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
215
216
217class MyActivity(object):
218 def __init__(self, options):
219 self.options = options
220 self.modified_after = options.begin
221 self.modified_before = options.end
222 self.user = options.user
223 self.changes = []
224 self.reviews = []
225 self.issues = []
226 self.check_cookies()
227 self.google_code_auth_token = None
228
229 # Check the codereview cookie jar to determine which Rietveld instances to
230 # authenticate to.
231 def check_cookies(self):
232 cookie_file = os.path.expanduser('~/.codereview_upload_cookies')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000233 if not os.path.exists(cookie_file):
deymo@chromium.org8aee4862013-11-13 19:33:27 +0000234 print 'No Rietveld cookie file found.'
235 cookie_jar = []
236 else:
237 cookie_jar = cookielib.MozillaCookieJar(cookie_file)
238 try:
239 cookie_jar.load()
240 print 'Found cookie file: %s' % cookie_file
241 except (cookielib.LoadError, IOError):
242 print 'Error loading Rietveld cookie file: %s' % cookie_file
243 cookie_jar = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000244
245 filtered_instances = []
246
247 def has_cookie(instance):
248 for cookie in cookie_jar:
249 if cookie.name == 'SACSID' and cookie.domain == instance['url']:
250 return True
251 if self.options.auth:
252 return get_yes_or_no('No cookie found for %s. Authorize for this '
253 'instance? (may require application-specific '
254 'password)' % instance['url'])
255 filtered_instances.append(instance)
256 return False
257
258 for instance in rietveld_instances:
259 instance['auth'] = has_cookie(instance)
260
261 if filtered_instances:
262 print ('No cookie found for the following Rietveld instance%s:' %
263 ('s' if len(filtered_instances) > 1 else ''))
264 for instance in filtered_instances:
265 print '\t' + instance['url']
266 print 'Use --auth if you would like to authenticate to them.\n'
267
268 def rietveld_search(self, instance, owner=None, reviewer=None):
269 if instance['requires_auth'] and not instance['auth']:
270 return []
271
272
273 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000274 auth_config = auth.extract_auth_config_from_options(self.options)
275 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000276
277 # See def search() in rietveld.py to see all the filters you can use.
278 query_modified_after = None
279
280 if instance['supports_owner_modified_query']:
281 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
282
283 # Rietveld does not allow search by both created_before and modified_after.
284 # (And some instances don't allow search by both owner and modified_after)
285 owner_email = None
286 reviewer_email = None
287 if owner:
288 owner_email = owner + '@' + instance['email_domain']
289 if reviewer:
290 reviewer_email = reviewer + '@' + instance['email_domain']
291 issues = remote.search(
292 owner=owner_email,
293 reviewer=reviewer_email,
294 modified_after=query_modified_after,
295 with_messages=True)
296
297 issues = filter(
298 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
299 issues)
300 issues = filter(
301 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
302 issues)
303
304 should_filter_by_user = True
305 issues = map(partial(self.process_rietveld_issue, instance), issues)
306 issues = filter(
307 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
308 issues)
309 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
310
311 return issues
312
313 def process_rietveld_issue(self, instance, issue):
314 ret = {}
315 ret['owner'] = issue['owner_email']
316 ret['author'] = ret['owner']
317
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000318 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000319
320 shorturl = instance['url']
321 if 'shorturl' in instance:
322 shorturl = instance['shorturl']
323
324 ret['review_url'] = 'http://%s/%d' % (shorturl, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000325
326 # Rietveld sometimes has '\r\n' instead of '\n'.
327 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000328
329 ret['modified'] = datetime_from_rietveld(issue['modified'])
330 ret['created'] = datetime_from_rietveld(issue['created'])
331 ret['replies'] = self.process_rietveld_replies(issue['messages'])
332
333 return ret
334
335 @staticmethod
336 def process_rietveld_replies(replies):
337 ret = []
338 for reply in replies:
339 r = {}
340 r['author'] = reply['sender']
341 r['created'] = datetime_from_rietveld(reply['date'])
342 r['content'] = ''
343 ret.append(r)
344 return ret
345
deymo@chromium.org6c039202013-09-12 12:28:12 +0000346 @staticmethod
347 def gerrit_changes_over_ssh(instance, filters):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000348 # See https://review.openstack.org/Documentation/cmd-query.html
349 # Gerrit doesn't allow filtering by created time, only modified time.
deymo@chromium.org6c039202013-09-12 12:28:12 +0000350 gquery_cmd = ['ssh', '-p', str(instance['port']), instance['host'],
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000351 'gerrit', 'query',
352 '--format', 'JSON',
353 '--comments',
deymo@chromium.org6c039202013-09-12 12:28:12 +0000354 '--'] + filters
355 (stdout, _) = subprocess.Popen(gquery_cmd, stdout=subprocess.PIPE,
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000356 stderr=subprocess.PIPE).communicate()
deymo@chromium.org6c039202013-09-12 12:28:12 +0000357 # Drop the last line of the output with the stats.
358 issues = stdout.splitlines()[:-1]
359 return map(json.loads, issues)
360
361 @staticmethod
362 def gerrit_changes_over_rest(instance, filters):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000363 # Convert the "key:value" filter to a dictionary.
364 req = dict(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000365 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000366 # Instantiate the generator to force all the requests now and catch the
367 # errors here.
368 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
369 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS']))
370 except gerrit_util.GerritError, e:
371 print 'ERROR: Looking up %r: %s' % (instance['url'], e)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000372 return []
373
deymo@chromium.org6c039202013-09-12 12:28:12 +0000374 def gerrit_search(self, instance, owner=None, reviewer=None):
375 max_age = datetime.today() - self.modified_after
376 max_age = max_age.days * 24 * 3600 + max_age.seconds
377 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
378 filters = ['-age:%ss' % max_age, user_filter]
379
380 # Determine the gerrit interface to use: SSH or REST API:
381 if 'host' in instance:
382 issues = self.gerrit_changes_over_ssh(instance, filters)
383 issues = [self.process_gerrit_ssh_issue(instance, issue)
384 for issue in issues]
385 elif 'url' in instance:
386 issues = self.gerrit_changes_over_rest(instance, filters)
387 issues = [self.process_gerrit_rest_issue(instance, issue)
388 for issue in issues]
389 else:
390 raise Exception('Invalid gerrit_instances configuration.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000391
392 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000393 issues = filter(self.filter_issue, issues)
394 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
395
396 return issues
397
deymo@chromium.org6c039202013-09-12 12:28:12 +0000398 def process_gerrit_ssh_issue(self, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000399 ret = {}
400 ret['review_url'] = issue['url']
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +0000401 if 'shorturl' in instance:
402 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
403 issue['number'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000404 ret['header'] = issue['subject']
405 ret['owner'] = issue['owner']['email']
406 ret['author'] = ret['owner']
407 ret['created'] = datetime.fromtimestamp(issue['createdOn'])
408 ret['modified'] = datetime.fromtimestamp(issue['lastUpdated'])
409 if 'comments' in issue:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000410 ret['replies'] = self.process_gerrit_ssh_issue_replies(issue['comments'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000411 else:
412 ret['replies'] = []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000413 ret['reviewers'] = set(r['author'] for r in ret['replies'])
414 ret['reviewers'].discard(ret['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000415 return ret
416
417 @staticmethod
deymo@chromium.org6c039202013-09-12 12:28:12 +0000418 def process_gerrit_ssh_issue_replies(replies):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000419 ret = []
420 replies = filter(lambda r: 'email' in r['reviewer'], replies)
421 for reply in replies:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000422 ret.append({
423 'author': reply['reviewer']['email'],
424 'created': datetime.fromtimestamp(reply['timestamp']),
425 'content': '',
426 })
427 return ret
428
429 def process_gerrit_rest_issue(self, instance, issue):
430 ret = {}
431 ret['review_url'] = 'https://%s/%s' % (instance['url'], issue['_number'])
432 if 'shorturl' in instance:
433 # TODO(deymo): Move this short link to https once crosreview.com supports
434 # it.
435 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
436 issue['_number'])
437 ret['header'] = issue['subject']
438 ret['owner'] = issue['owner']['email']
439 ret['author'] = ret['owner']
440 ret['created'] = datetime_from_gerrit(issue['created'])
441 ret['modified'] = datetime_from_gerrit(issue['updated'])
442 if 'messages' in issue:
443 ret['replies'] = self.process_gerrit_rest_issue_replies(issue['messages'])
444 else:
445 ret['replies'] = []
446 ret['reviewers'] = set(r['author'] for r in ret['replies'])
447 ret['reviewers'].discard(ret['author'])
448 return ret
449
450 @staticmethod
451 def process_gerrit_rest_issue_replies(replies):
452 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000453 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
454 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000455 for reply in replies:
456 ret.append({
457 'author': reply['author']['email'],
458 'created': datetime_from_gerrit(reply['date']),
459 'content': reply['message'],
460 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000461 return ret
462
463 def google_code_issue_search(self, instance):
464 time_format = '%Y-%m-%dT%T'
465 # See http://code.google.com/p/support/wiki/IssueTrackerAPI
466 # q=<owner>@chromium.org does a full text search for <owner>@chromium.org.
467 # This will accept the issue if owner is the owner or in the cc list. Might
468 # have some false positives, though.
469
470 # Don't filter normally on modified_before because it can filter out things
471 # that were modified in the time period and then modified again after it.
472 gcode_url = ('https://code.google.com/feeds/issues/p/%s/issues/full' %
473 instance['name'])
474
475 gcode_data = urllib.urlencode({
476 'alt': 'json',
477 'max-results': '100000',
478 'q': '%s' % self.user,
479 'published-max': self.modified_before.strftime(time_format),
480 'updated-min': self.modified_after.strftime(time_format),
481 })
482
483 opener = urllib2.build_opener()
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000484 if self.google_code_auth_token:
485 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
486 self.google_code_auth_token)]
487 gcode_json = None
488 try:
489 gcode_get = opener.open(gcode_url + '?' + gcode_data)
490 gcode_json = json.load(gcode_get)
491 gcode_get.close()
492 except urllib2.HTTPError, _:
493 print 'Unable to access ' + instance['name'] + ' issue tracker.'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000494
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000495 if not gcode_json or 'entry' not in gcode_json['feed']:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000496 return []
497
498 issues = gcode_json['feed']['entry']
499 issues = map(partial(self.process_google_code_issue, instance), issues)
500 issues = filter(self.filter_issue, issues)
501 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
502 return issues
503
504 def process_google_code_issue(self, project, issue):
505 ret = {}
506 ret['created'] = datetime_from_google_code(issue['published']['$t'])
507 ret['modified'] = datetime_from_google_code(issue['updated']['$t'])
508
509 ret['owner'] = ''
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000510 if 'issues$owner' in issue:
511 ret['owner'] = issue['issues$owner']['issues$username']['$t']
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000512 ret['author'] = issue['author'][0]['name']['$t']
513
514 if 'shorturl' in project:
515 issue_id = issue['id']['$t']
516 issue_id = issue_id[issue_id.rfind('/') + 1:]
517 ret['url'] = 'http://%s/%d' % (project['shorturl'], int(issue_id))
518 else:
519 issue_url = issue['link'][1]
520 if issue_url['rel'] != 'alternate':
521 raise RuntimeError
522 ret['url'] = issue_url['href']
523 ret['header'] = issue['title']['$t']
524
525 ret['replies'] = self.get_google_code_issue_replies(issue)
526 return ret
527
528 def get_google_code_issue_replies(self, issue):
529 """Get all the comments on the issue."""
530 replies_url = issue['link'][0]
531 if replies_url['rel'] != 'replies':
532 raise RuntimeError
533
534 replies_data = urllib.urlencode({
535 'alt': 'json',
536 'fields': 'entry(published,author,content)',
537 })
538
539 opener = urllib2.build_opener()
540 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
541 self.google_code_auth_token)]
542 try:
543 replies_get = opener.open(replies_url['href'] + '?' + replies_data)
544 except urllib2.HTTPError, _:
545 return []
546
547 replies_json = json.load(replies_get)
548 replies_get.close()
549 return self.process_google_code_issue_replies(replies_json)
550
551 @staticmethod
552 def process_google_code_issue_replies(replies):
553 if 'entry' not in replies['feed']:
554 return []
555
556 ret = []
557 for entry in replies['feed']['entry']:
558 e = {}
559 e['created'] = datetime_from_google_code(entry['published']['$t'])
560 e['content'] = entry['content']['$t']
561 e['author'] = entry['author'][0]['name']['$t']
562 ret.append(e)
563 return ret
564
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000565 def print_heading(self, heading):
566 print
567 print self.options.output_format_heading.format(heading=heading)
568
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000569 def print_change(self, change):
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000570 optional_values = {
571 'reviewers': ', '.join(change['reviewers'])
572 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000573 self.print_generic(self.options.output_format,
574 self.options.output_format_changes,
575 change['header'],
576 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000577 change['author'],
578 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000579
580 def print_issue(self, issue):
581 optional_values = {
582 'owner': issue['owner'],
583 }
584 self.print_generic(self.options.output_format,
585 self.options.output_format_issues,
586 issue['header'],
587 issue['url'],
588 issue['author'],
589 optional_values)
590
591 def print_review(self, review):
592 self.print_generic(self.options.output_format,
593 self.options.output_format_reviews,
594 review['header'],
595 review['review_url'],
596 review['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000597
598 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000599 def print_generic(default_fmt, specific_fmt,
600 title, url, author,
601 optional_values=None):
602 output_format = specific_fmt if specific_fmt is not None else default_fmt
603 output_format = unicode(output_format)
604 required_values = {
605 'title': title,
606 'url': url,
607 'author': author,
608 }
609 # Merge required and optional values.
610 if optional_values is not None:
611 values = dict(required_values.items() + optional_values.items())
612 else:
613 values = required_values
614 print output_format.format(**values)
615
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000616
617 def filter_issue(self, issue, should_filter_by_user=True):
618 def maybe_filter_username(email):
619 return not should_filter_by_user or username(email) == self.user
620 if (maybe_filter_username(issue['author']) and
621 self.filter_modified(issue['created'])):
622 return True
623 if (maybe_filter_username(issue['owner']) and
624 (self.filter_modified(issue['created']) or
625 self.filter_modified(issue['modified']))):
626 return True
627 for reply in issue['replies']:
628 if self.filter_modified(reply['created']):
629 if not should_filter_by_user:
630 break
631 if (username(reply['author']) == self.user
632 or (self.user + '@') in reply['content']):
633 break
634 else:
635 return False
636 return True
637
638 def filter_modified(self, modified):
639 return self.modified_after < modified and modified < self.modified_before
640
641 def auth_for_changes(self):
642 #TODO(cjhopman): Move authentication check for getting changes here.
643 pass
644
645 def auth_for_reviews(self):
646 # Reviews use all the same instances as changes so no authentication is
647 # required.
648 pass
649
650 def auth_for_issues(self):
651 self.google_code_auth_token = (
652 get_auth_token(self.options.local_user + '@chromium.org'))
653
654 def get_changes(self):
655 for instance in rietveld_instances:
656 self.changes += self.rietveld_search(instance, owner=self.user)
657
658 for instance in gerrit_instances:
659 self.changes += self.gerrit_search(instance, owner=self.user)
660
661 def print_changes(self):
662 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000663 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000664 for change in self.changes:
665 self.print_change(change)
666
667 def get_reviews(self):
668 for instance in rietveld_instances:
669 self.reviews += self.rietveld_search(instance, reviewer=self.user)
670
671 for instance in gerrit_instances:
672 reviews = self.gerrit_search(instance, reviewer=self.user)
673 reviews = filter(lambda r: not username(r['owner']) == self.user, reviews)
674 self.reviews += reviews
675
676 def print_reviews(self):
677 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000678 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000679 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000680 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000681
682 def get_issues(self):
683 for project in google_code_projects:
684 self.issues += self.google_code_issue_search(project)
685
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000686 def print_issues(self):
687 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000688 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000689 for issue in self.issues:
690 self.print_issue(issue)
691
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000692 def print_activity(self):
693 self.print_changes()
694 self.print_reviews()
695 self.print_issues()
696
697
698def main():
699 # Silence upload.py.
700 rietveld.upload.verbosity = 0
701
702 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
703 parser.add_option(
704 '-u', '--user', metavar='<email>',
705 default=os.environ.get('USER'),
706 help='Filter on user, default=%default')
707 parser.add_option(
708 '-b', '--begin', metavar='<date>',
709 help='Filter issues created after the date')
710 parser.add_option(
711 '-e', '--end', metavar='<date>',
712 help='Filter issues created before the date')
713 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
714 relativedelta(months=2))
715 parser.add_option(
716 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000717 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000718 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
719 parser.add_option(
720 '-Y', '--this_year', action='store_true',
721 help='Use this year\'s dates')
722 parser.add_option(
723 '-w', '--week_of', metavar='<date>',
724 help='Show issues for week of the date')
725 parser.add_option(
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000726 '-W', '--last_week', action='store_true',
727 help='Show last week\'s issues')
728 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000729 '-a', '--auth',
730 action='store_true',
731 help='Ask to authenticate for instances with no auth cookie')
732
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000733 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000734 'By default, all activity will be looked up and '
735 'printed. If any of these are specified, only '
736 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000737 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000738 '-c', '--changes',
739 action='store_true',
740 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000741 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000742 '-i', '--issues',
743 action='store_true',
744 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000745 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000746 '-r', '--reviews',
747 action='store_true',
748 help='Show reviews.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000749 parser.add_option_group(activity_types_group)
750
751 output_format_group = optparse.OptionGroup(parser, 'Output Format',
752 'By default, all activity will be printed in the '
753 'following format: {url} {title}. This can be '
754 'changed for either all activity types or '
755 'individually for each activity type. The format '
756 'is defined as documented for '
757 'string.format(...). The variables available for '
758 'all activity types are url, title and author. '
759 'Format options for specific activity types will '
760 'override the generic format.')
761 output_format_group.add_option(
762 '-f', '--output-format', metavar='<format>',
763 default=u'{url} {title}',
764 help='Specifies the format to use when printing all your activity.')
765 output_format_group.add_option(
766 '--output-format-changes', metavar='<format>',
767 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000768 help='Specifies the format to use when printing changes. Supports the '
769 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000770 output_format_group.add_option(
771 '--output-format-issues', metavar='<format>',
772 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000773 help='Specifies the format to use when printing issues. Supports the '
774 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000775 output_format_group.add_option(
776 '--output-format-reviews', metavar='<format>',
777 default=None,
778 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000779 output_format_group.add_option(
780 '--output-format-heading', metavar='<format>',
781 default=u'{heading}:',
782 help='Specifies the format to use when printing headings.')
783 output_format_group.add_option(
784 '-m', '--markdown', action='store_true',
785 help='Use markdown-friendly output (overrides --output-format '
786 'and --output-format-heading)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000787 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000788 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000789
790 # Remove description formatting
791 parser.format_description = (
792 lambda _: parser.description) # pylint: disable=E1101
793
794 options, args = parser.parse_args()
795 options.local_user = os.environ.get('USER')
796 if args:
797 parser.error('Args unsupported')
798 if not options.user:
799 parser.error('USER is not set, please use -u')
800
801 options.user = username(options.user)
802
803 if not options.begin:
804 if options.last_quarter:
805 begin, end = quarter_begin, quarter_end
806 elif options.this_year:
807 begin, end = get_year_of(datetime.today())
808 elif options.week_of:
809 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000810 elif options.last_week:
811 begin, end = (get_week_of(datetime.today() - timedelta(days=7)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000812 else:
813 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
814 else:
815 begin = datetime.strptime(options.begin, '%m/%d/%y')
816 if options.end:
817 end = datetime.strptime(options.end, '%m/%d/%y')
818 else:
819 end = datetime.today()
820 options.begin, options.end = begin, end
821
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000822 if options.markdown:
823 options.output_format = ' * [{title}]({url})'
824 options.output_format_heading = '### {heading} ###'
825
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000826 print 'Searching for activity by %s' % options.user
827 print 'Using range %s to %s' % (options.begin, options.end)
828
829 my_activity = MyActivity(options)
830
831 if not (options.changes or options.reviews or options.issues):
832 options.changes = True
833 options.issues = True
834 options.reviews = True
835
836 # First do any required authentication so none of the user interaction has to
837 # wait for actual work.
838 if options.changes:
839 my_activity.auth_for_changes()
840 if options.reviews:
841 my_activity.auth_for_reviews()
842 if options.issues:
843 my_activity.auth_for_issues()
844
845 print 'Looking up activity.....'
846
847 if options.changes:
848 my_activity.get_changes()
849 if options.reviews:
850 my_activity.get_reviews()
851 if options.issues:
852 my_activity.get_issues()
853
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000854 print '\n\n\n'
855
856 my_activity.print_changes()
857 my_activity.print_reviews()
858 my_activity.print_issues()
859 return 0
860
861
862if __name__ == '__main__':
sbc@chromium.org013731e2015-02-26 18:28:43 +0000863 try:
864 sys.exit(main())
865 except KeyboardInterrupt:
866 sys.stderr.write('interrupted\n')
867 sys.exit(1)