blob: 48aeaea7b48b71a5277f9a138743779b9b918956 [file] [log] [blame]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001#!/usr/bin/env python
2# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Get stats about your activity.
7
8Example:
9 - my_activity.py for stats for the current week (last week on mondays).
10 - my_activity.py -Q for stats for last quarter.
11 - my_activity.py -Y for stats for this year.
12 - my_activity.py -b 4/5/12 for stats since 4/5/12.
13 - my_activity.py -b 4/5/12 -e 6/7/12 for stats between 4/5/12 and 6/7/12.
14"""
15
16# These services typically only provide a created time and a last modified time
17# for each item for general queries. This is not enough to determine if there
18# was activity in a given time period. So, we first query for all things created
19# before end and modified after begin. Then, we get the details of each item and
20# check those details to determine if there was activity in the given period.
21# This means that query time scales mostly with (today() - begin).
22
23import cookielib
24import datetime
25from datetime import datetime
26from datetime import timedelta
27from functools import partial
28import json
29import optparse
30import os
31import subprocess
32import sys
33import urllib
34import urllib2
35
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000036import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000037import rietveld
38from third_party import upload
39
40try:
41 from dateutil.relativedelta import relativedelta # pylint: disable=F0401
42except ImportError:
43 print 'python-dateutil package required'
44 exit(1)
45
46# python-keyring provides easy access to the system keyring.
47try:
48 import keyring # pylint: disable=W0611,F0401
49except ImportError:
50 print 'Consider installing python-keyring'
51
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000052rietveld_instances = [
53 {
54 'url': 'codereview.chromium.org',
55 'shorturl': 'crrev.com',
56 'supports_owner_modified_query': True,
57 'requires_auth': False,
58 'email_domain': 'chromium.org',
59 },
60 {
61 'url': 'chromereviews.googleplex.com',
62 'shorturl': 'go/chromerev',
63 'supports_owner_modified_query': True,
64 'requires_auth': True,
65 'email_domain': 'google.com',
66 },
67 {
68 'url': 'codereview.appspot.com',
69 'supports_owner_modified_query': True,
70 'requires_auth': False,
71 'email_domain': 'chromium.org',
72 },
73 {
74 'url': 'breakpad.appspot.com',
75 'supports_owner_modified_query': False,
76 'requires_auth': False,
77 'email_domain': 'chromium.org',
78 },
79]
80
81gerrit_instances = [
82 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000083 'url': 'chromium-review.googlesource.com',
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +000084 'shorturl': 'crosreview.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000085 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000086 {
87 'url': 'chrome-internal-review.googlesource.com',
88 'shorturl': 'crosreview.com/i',
89 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000090 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000091 'host': 'gerrit.chromium.org',
92 'port': 29418,
93 },
94 {
95 'host': 'gerrit-int.chromium.org',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000096 'port': 29419,
97 },
98]
99
100google_code_projects = [
101 {
102 'name': 'chromium',
103 'shorturl': 'crbug.com',
104 },
105 {
106 'name': 'chromium-os',
deymo@chromium.orgc840e212013-02-13 20:40:22 +0000107 'shorturl': 'crosbug.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000108 },
109 {
110 'name': 'chrome-os-partner',
111 },
112 {
113 'name': 'google-breakpad',
114 },
115 {
116 'name': 'gyp',
enne@chromium.orgf01fad32012-11-26 18:09:38 +0000117 },
118 {
119 'name': 'skia',
120 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000121]
122
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000123# Uses ClientLogin to authenticate the user for Google Code issue trackers.
124def get_auth_token(email):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000125 # KeyringCreds will use the system keyring on the first try, and prompt for
126 # a password on the next ones.
127 creds = upload.KeyringCreds('code.google.com', 'code.google.com', email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000128 for _ in xrange(3):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000129 email, password = creds.GetUserCredentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000130 url = 'https://www.google.com/accounts/ClientLogin'
131 data = urllib.urlencode({
132 'Email': email,
133 'Passwd': password,
134 'service': 'code',
135 'source': 'chrome-my-activity',
136 'accountType': 'GOOGLE',
137 })
138 req = urllib2.Request(url, data=data, headers={'Accept': 'text/plain'})
139 try:
140 response = urllib2.urlopen(req)
141 response_body = response.read()
142 response_dict = dict(x.split('=')
143 for x in response_body.split('\n') if x)
144 return response_dict['Auth']
145 except urllib2.HTTPError, e:
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000146 print e
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000147
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000148 print 'Unable to authenticate to code.google.com.'
149 print 'Some issues may be missing.'
150 return None
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000151
152
153def username(email):
154 """Keeps the username of an email address."""
155 return email and email.split('@', 1)[0]
156
157
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000158def datetime_to_midnight(date):
159 return date - timedelta(hours=date.hour, minutes=date.minute,
160 seconds=date.second, microseconds=date.microsecond)
161
162
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000163def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000164 begin = (datetime_to_midnight(date) -
165 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000166 return begin, begin + relativedelta(months=3)
167
168
169def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000170 begin = (datetime_to_midnight(date) -
171 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000172 return begin, begin + relativedelta(years=1)
173
174
175def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000176 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000177 return begin, begin + timedelta(days=7)
178
179
180def get_yes_or_no(msg):
181 while True:
182 response = raw_input(msg + ' yes/no [no] ')
183 if response == 'y' or response == 'yes':
184 return True
185 elif not response or response == 'n' or response == 'no':
186 return False
187
188
deymo@chromium.org6c039202013-09-12 12:28:12 +0000189def datetime_from_gerrit(date_string):
190 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
191
192
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000193def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000194 try:
195 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
196 except ValueError:
197 # Sometimes rietveld returns a value without the milliseconds part, so we
198 # attempt to parse those cases as well.
199 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000200
201
202def datetime_from_google_code(date_string):
203 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
204
205
206class MyActivity(object):
207 def __init__(self, options):
208 self.options = options
209 self.modified_after = options.begin
210 self.modified_before = options.end
211 self.user = options.user
212 self.changes = []
213 self.reviews = []
214 self.issues = []
215 self.check_cookies()
216 self.google_code_auth_token = None
217
218 # Check the codereview cookie jar to determine which Rietveld instances to
219 # authenticate to.
220 def check_cookies(self):
221 cookie_file = os.path.expanduser('~/.codereview_upload_cookies')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000222 if not os.path.exists(cookie_file):
deymo@chromium.org8aee4862013-11-13 19:33:27 +0000223 print 'No Rietveld cookie file found.'
224 cookie_jar = []
225 else:
226 cookie_jar = cookielib.MozillaCookieJar(cookie_file)
227 try:
228 cookie_jar.load()
229 print 'Found cookie file: %s' % cookie_file
230 except (cookielib.LoadError, IOError):
231 print 'Error loading Rietveld cookie file: %s' % cookie_file
232 cookie_jar = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000233
234 filtered_instances = []
235
236 def has_cookie(instance):
237 for cookie in cookie_jar:
238 if cookie.name == 'SACSID' and cookie.domain == instance['url']:
239 return True
240 if self.options.auth:
241 return get_yes_or_no('No cookie found for %s. Authorize for this '
242 'instance? (may require application-specific '
243 'password)' % instance['url'])
244 filtered_instances.append(instance)
245 return False
246
247 for instance in rietveld_instances:
248 instance['auth'] = has_cookie(instance)
249
250 if filtered_instances:
251 print ('No cookie found for the following Rietveld instance%s:' %
252 ('s' if len(filtered_instances) > 1 else ''))
253 for instance in filtered_instances:
254 print '\t' + instance['url']
255 print 'Use --auth if you would like to authenticate to them.\n'
256
257 def rietveld_search(self, instance, owner=None, reviewer=None):
258 if instance['requires_auth'] and not instance['auth']:
259 return []
260
261
262 email = None if instance['auth'] else ''
263 remote = rietveld.Rietveld('https://' + instance['url'], email, None)
264
265 # See def search() in rietveld.py to see all the filters you can use.
266 query_modified_after = None
267
268 if instance['supports_owner_modified_query']:
269 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
270
271 # Rietveld does not allow search by both created_before and modified_after.
272 # (And some instances don't allow search by both owner and modified_after)
273 owner_email = None
274 reviewer_email = None
275 if owner:
276 owner_email = owner + '@' + instance['email_domain']
277 if reviewer:
278 reviewer_email = reviewer + '@' + instance['email_domain']
279 issues = remote.search(
280 owner=owner_email,
281 reviewer=reviewer_email,
282 modified_after=query_modified_after,
283 with_messages=True)
284
285 issues = filter(
286 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
287 issues)
288 issues = filter(
289 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
290 issues)
291
292 should_filter_by_user = True
293 issues = map(partial(self.process_rietveld_issue, instance), issues)
294 issues = filter(
295 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
296 issues)
297 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
298
299 return issues
300
301 def process_rietveld_issue(self, instance, issue):
302 ret = {}
303 ret['owner'] = issue['owner_email']
304 ret['author'] = ret['owner']
305
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000306 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000307
308 shorturl = instance['url']
309 if 'shorturl' in instance:
310 shorturl = instance['shorturl']
311
312 ret['review_url'] = 'http://%s/%d' % (shorturl, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000313
314 # Rietveld sometimes has '\r\n' instead of '\n'.
315 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000316
317 ret['modified'] = datetime_from_rietveld(issue['modified'])
318 ret['created'] = datetime_from_rietveld(issue['created'])
319 ret['replies'] = self.process_rietveld_replies(issue['messages'])
320
321 return ret
322
323 @staticmethod
324 def process_rietveld_replies(replies):
325 ret = []
326 for reply in replies:
327 r = {}
328 r['author'] = reply['sender']
329 r['created'] = datetime_from_rietveld(reply['date'])
330 r['content'] = ''
331 ret.append(r)
332 return ret
333
deymo@chromium.org6c039202013-09-12 12:28:12 +0000334 @staticmethod
335 def gerrit_changes_over_ssh(instance, filters):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000336 # See https://review.openstack.org/Documentation/cmd-query.html
337 # Gerrit doesn't allow filtering by created time, only modified time.
deymo@chromium.org6c039202013-09-12 12:28:12 +0000338 gquery_cmd = ['ssh', '-p', str(instance['port']), instance['host'],
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000339 'gerrit', 'query',
340 '--format', 'JSON',
341 '--comments',
deymo@chromium.org6c039202013-09-12 12:28:12 +0000342 '--'] + filters
343 (stdout, _) = subprocess.Popen(gquery_cmd, stdout=subprocess.PIPE,
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000344 stderr=subprocess.PIPE).communicate()
deymo@chromium.org6c039202013-09-12 12:28:12 +0000345 # Drop the last line of the output with the stats.
346 issues = stdout.splitlines()[:-1]
347 return map(json.loads, issues)
348
349 @staticmethod
350 def gerrit_changes_over_rest(instance, filters):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000351 # Convert the "key:value" filter to a dictionary.
352 req = dict(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000353 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000354 # Instantiate the generator to force all the requests now and catch the
355 # errors here.
356 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
357 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS']))
358 except gerrit_util.GerritError, e:
359 print 'ERROR: Looking up %r: %s' % (instance['url'], e)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000360 return []
361
deymo@chromium.org6c039202013-09-12 12:28:12 +0000362 def gerrit_search(self, instance, owner=None, reviewer=None):
363 max_age = datetime.today() - self.modified_after
364 max_age = max_age.days * 24 * 3600 + max_age.seconds
365 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
366 filters = ['-age:%ss' % max_age, user_filter]
367
368 # Determine the gerrit interface to use: SSH or REST API:
369 if 'host' in instance:
370 issues = self.gerrit_changes_over_ssh(instance, filters)
371 issues = [self.process_gerrit_ssh_issue(instance, issue)
372 for issue in issues]
373 elif 'url' in instance:
374 issues = self.gerrit_changes_over_rest(instance, filters)
375 issues = [self.process_gerrit_rest_issue(instance, issue)
376 for issue in issues]
377 else:
378 raise Exception('Invalid gerrit_instances configuration.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000379
380 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000381 issues = filter(self.filter_issue, issues)
382 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
383
384 return issues
385
deymo@chromium.org6c039202013-09-12 12:28:12 +0000386 def process_gerrit_ssh_issue(self, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000387 ret = {}
388 ret['review_url'] = issue['url']
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +0000389 if 'shorturl' in instance:
390 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
391 issue['number'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000392 ret['header'] = issue['subject']
393 ret['owner'] = issue['owner']['email']
394 ret['author'] = ret['owner']
395 ret['created'] = datetime.fromtimestamp(issue['createdOn'])
396 ret['modified'] = datetime.fromtimestamp(issue['lastUpdated'])
397 if 'comments' in issue:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000398 ret['replies'] = self.process_gerrit_ssh_issue_replies(issue['comments'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000399 else:
400 ret['replies'] = []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000401 ret['reviewers'] = set(r['author'] for r in ret['replies'])
402 ret['reviewers'].discard(ret['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000403 return ret
404
405 @staticmethod
deymo@chromium.org6c039202013-09-12 12:28:12 +0000406 def process_gerrit_ssh_issue_replies(replies):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000407 ret = []
408 replies = filter(lambda r: 'email' in r['reviewer'], replies)
409 for reply in replies:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000410 ret.append({
411 'author': reply['reviewer']['email'],
412 'created': datetime.fromtimestamp(reply['timestamp']),
413 'content': '',
414 })
415 return ret
416
417 def process_gerrit_rest_issue(self, instance, issue):
418 ret = {}
419 ret['review_url'] = 'https://%s/%s' % (instance['url'], issue['_number'])
420 if 'shorturl' in instance:
421 # TODO(deymo): Move this short link to https once crosreview.com supports
422 # it.
423 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
424 issue['_number'])
425 ret['header'] = issue['subject']
426 ret['owner'] = issue['owner']['email']
427 ret['author'] = ret['owner']
428 ret['created'] = datetime_from_gerrit(issue['created'])
429 ret['modified'] = datetime_from_gerrit(issue['updated'])
430 if 'messages' in issue:
431 ret['replies'] = self.process_gerrit_rest_issue_replies(issue['messages'])
432 else:
433 ret['replies'] = []
434 ret['reviewers'] = set(r['author'] for r in ret['replies'])
435 ret['reviewers'].discard(ret['author'])
436 return ret
437
438 @staticmethod
439 def process_gerrit_rest_issue_replies(replies):
440 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000441 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
442 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000443 for reply in replies:
444 ret.append({
445 'author': reply['author']['email'],
446 'created': datetime_from_gerrit(reply['date']),
447 'content': reply['message'],
448 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000449 return ret
450
451 def google_code_issue_search(self, instance):
452 time_format = '%Y-%m-%dT%T'
453 # See http://code.google.com/p/support/wiki/IssueTrackerAPI
454 # q=<owner>@chromium.org does a full text search for <owner>@chromium.org.
455 # This will accept the issue if owner is the owner or in the cc list. Might
456 # have some false positives, though.
457
458 # Don't filter normally on modified_before because it can filter out things
459 # that were modified in the time period and then modified again after it.
460 gcode_url = ('https://code.google.com/feeds/issues/p/%s/issues/full' %
461 instance['name'])
462
463 gcode_data = urllib.urlencode({
464 'alt': 'json',
465 'max-results': '100000',
466 'q': '%s' % self.user,
467 'published-max': self.modified_before.strftime(time_format),
468 'updated-min': self.modified_after.strftime(time_format),
469 })
470
471 opener = urllib2.build_opener()
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000472 if self.google_code_auth_token:
473 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
474 self.google_code_auth_token)]
475 gcode_json = None
476 try:
477 gcode_get = opener.open(gcode_url + '?' + gcode_data)
478 gcode_json = json.load(gcode_get)
479 gcode_get.close()
480 except urllib2.HTTPError, _:
481 print 'Unable to access ' + instance['name'] + ' issue tracker.'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000482
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000483 if not gcode_json or 'entry' not in gcode_json['feed']:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000484 return []
485
486 issues = gcode_json['feed']['entry']
487 issues = map(partial(self.process_google_code_issue, instance), issues)
488 issues = filter(self.filter_issue, issues)
489 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
490 return issues
491
492 def process_google_code_issue(self, project, issue):
493 ret = {}
494 ret['created'] = datetime_from_google_code(issue['published']['$t'])
495 ret['modified'] = datetime_from_google_code(issue['updated']['$t'])
496
497 ret['owner'] = ''
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000498 if 'issues$owner' in issue:
499 ret['owner'] = issue['issues$owner']['issues$username']['$t']
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000500 ret['author'] = issue['author'][0]['name']['$t']
501
502 if 'shorturl' in project:
503 issue_id = issue['id']['$t']
504 issue_id = issue_id[issue_id.rfind('/') + 1:]
505 ret['url'] = 'http://%s/%d' % (project['shorturl'], int(issue_id))
506 else:
507 issue_url = issue['link'][1]
508 if issue_url['rel'] != 'alternate':
509 raise RuntimeError
510 ret['url'] = issue_url['href']
511 ret['header'] = issue['title']['$t']
512
513 ret['replies'] = self.get_google_code_issue_replies(issue)
514 return ret
515
516 def get_google_code_issue_replies(self, issue):
517 """Get all the comments on the issue."""
518 replies_url = issue['link'][0]
519 if replies_url['rel'] != 'replies':
520 raise RuntimeError
521
522 replies_data = urllib.urlencode({
523 'alt': 'json',
524 'fields': 'entry(published,author,content)',
525 })
526
527 opener = urllib2.build_opener()
528 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
529 self.google_code_auth_token)]
530 try:
531 replies_get = opener.open(replies_url['href'] + '?' + replies_data)
532 except urllib2.HTTPError, _:
533 return []
534
535 replies_json = json.load(replies_get)
536 replies_get.close()
537 return self.process_google_code_issue_replies(replies_json)
538
539 @staticmethod
540 def process_google_code_issue_replies(replies):
541 if 'entry' not in replies['feed']:
542 return []
543
544 ret = []
545 for entry in replies['feed']['entry']:
546 e = {}
547 e['created'] = datetime_from_google_code(entry['published']['$t'])
548 e['content'] = entry['content']['$t']
549 e['author'] = entry['author'][0]['name']['$t']
550 ret.append(e)
551 return ret
552
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000553 def print_heading(self, heading):
554 print
555 print self.options.output_format_heading.format(heading=heading)
556
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000557 def print_change(self, change):
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000558 optional_values = {
559 'reviewers': ', '.join(change['reviewers'])
560 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000561 self.print_generic(self.options.output_format,
562 self.options.output_format_changes,
563 change['header'],
564 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000565 change['author'],
566 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000567
568 def print_issue(self, issue):
569 optional_values = {
570 'owner': issue['owner'],
571 }
572 self.print_generic(self.options.output_format,
573 self.options.output_format_issues,
574 issue['header'],
575 issue['url'],
576 issue['author'],
577 optional_values)
578
579 def print_review(self, review):
580 self.print_generic(self.options.output_format,
581 self.options.output_format_reviews,
582 review['header'],
583 review['review_url'],
584 review['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000585
586 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000587 def print_generic(default_fmt, specific_fmt,
588 title, url, author,
589 optional_values=None):
590 output_format = specific_fmt if specific_fmt is not None else default_fmt
591 output_format = unicode(output_format)
592 required_values = {
593 'title': title,
594 'url': url,
595 'author': author,
596 }
597 # Merge required and optional values.
598 if optional_values is not None:
599 values = dict(required_values.items() + optional_values.items())
600 else:
601 values = required_values
602 print output_format.format(**values)
603
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000604
605 def filter_issue(self, issue, should_filter_by_user=True):
606 def maybe_filter_username(email):
607 return not should_filter_by_user or username(email) == self.user
608 if (maybe_filter_username(issue['author']) and
609 self.filter_modified(issue['created'])):
610 return True
611 if (maybe_filter_username(issue['owner']) and
612 (self.filter_modified(issue['created']) or
613 self.filter_modified(issue['modified']))):
614 return True
615 for reply in issue['replies']:
616 if self.filter_modified(reply['created']):
617 if not should_filter_by_user:
618 break
619 if (username(reply['author']) == self.user
620 or (self.user + '@') in reply['content']):
621 break
622 else:
623 return False
624 return True
625
626 def filter_modified(self, modified):
627 return self.modified_after < modified and modified < self.modified_before
628
629 def auth_for_changes(self):
630 #TODO(cjhopman): Move authentication check for getting changes here.
631 pass
632
633 def auth_for_reviews(self):
634 # Reviews use all the same instances as changes so no authentication is
635 # required.
636 pass
637
638 def auth_for_issues(self):
639 self.google_code_auth_token = (
640 get_auth_token(self.options.local_user + '@chromium.org'))
641
642 def get_changes(self):
643 for instance in rietveld_instances:
644 self.changes += self.rietveld_search(instance, owner=self.user)
645
646 for instance in gerrit_instances:
647 self.changes += self.gerrit_search(instance, owner=self.user)
648
649 def print_changes(self):
650 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000651 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000652 for change in self.changes:
653 self.print_change(change)
654
655 def get_reviews(self):
656 for instance in rietveld_instances:
657 self.reviews += self.rietveld_search(instance, reviewer=self.user)
658
659 for instance in gerrit_instances:
660 reviews = self.gerrit_search(instance, reviewer=self.user)
661 reviews = filter(lambda r: not username(r['owner']) == self.user, reviews)
662 self.reviews += reviews
663
664 def print_reviews(self):
665 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000666 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000667 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000668 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000669
670 def get_issues(self):
671 for project in google_code_projects:
672 self.issues += self.google_code_issue_search(project)
673
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000674 def print_issues(self):
675 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000676 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000677 for issue in self.issues:
678 self.print_issue(issue)
679
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000680 def print_activity(self):
681 self.print_changes()
682 self.print_reviews()
683 self.print_issues()
684
685
686def main():
687 # Silence upload.py.
688 rietveld.upload.verbosity = 0
689
690 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
691 parser.add_option(
692 '-u', '--user', metavar='<email>',
693 default=os.environ.get('USER'),
694 help='Filter on user, default=%default')
695 parser.add_option(
696 '-b', '--begin', metavar='<date>',
697 help='Filter issues created after the date')
698 parser.add_option(
699 '-e', '--end', metavar='<date>',
700 help='Filter issues created before the date')
701 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
702 relativedelta(months=2))
703 parser.add_option(
704 '-Q', '--last_quarter', action='store_true',
705 help='Use last quarter\'s dates, e.g. %s to %s' % (
706 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
707 parser.add_option(
708 '-Y', '--this_year', action='store_true',
709 help='Use this year\'s dates')
710 parser.add_option(
711 '-w', '--week_of', metavar='<date>',
712 help='Show issues for week of the date')
713 parser.add_option(
714 '-a', '--auth',
715 action='store_true',
716 help='Ask to authenticate for instances with no auth cookie')
717
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000718 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000719 'By default, all activity will be looked up and '
720 'printed. If any of these are specified, only '
721 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000722 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000723 '-c', '--changes',
724 action='store_true',
725 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000726 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000727 '-i', '--issues',
728 action='store_true',
729 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000730 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000731 '-r', '--reviews',
732 action='store_true',
733 help='Show reviews.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000734 parser.add_option_group(activity_types_group)
735
736 output_format_group = optparse.OptionGroup(parser, 'Output Format',
737 'By default, all activity will be printed in the '
738 'following format: {url} {title}. This can be '
739 'changed for either all activity types or '
740 'individually for each activity type. The format '
741 'is defined as documented for '
742 'string.format(...). The variables available for '
743 'all activity types are url, title and author. '
744 'Format options for specific activity types will '
745 'override the generic format.')
746 output_format_group.add_option(
747 '-f', '--output-format', metavar='<format>',
748 default=u'{url} {title}',
749 help='Specifies the format to use when printing all your activity.')
750 output_format_group.add_option(
751 '--output-format-changes', metavar='<format>',
752 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000753 help='Specifies the format to use when printing changes. Supports the '
754 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000755 output_format_group.add_option(
756 '--output-format-issues', metavar='<format>',
757 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000758 help='Specifies the format to use when printing issues. Supports the '
759 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000760 output_format_group.add_option(
761 '--output-format-reviews', metavar='<format>',
762 default=None,
763 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000764 output_format_group.add_option(
765 '--output-format-heading', metavar='<format>',
766 default=u'{heading}:',
767 help='Specifies the format to use when printing headings.')
768 output_format_group.add_option(
769 '-m', '--markdown', action='store_true',
770 help='Use markdown-friendly output (overrides --output-format '
771 'and --output-format-heading)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000772 parser.add_option_group(output_format_group)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000773
774 # Remove description formatting
775 parser.format_description = (
776 lambda _: parser.description) # pylint: disable=E1101
777
778 options, args = parser.parse_args()
779 options.local_user = os.environ.get('USER')
780 if args:
781 parser.error('Args unsupported')
782 if not options.user:
783 parser.error('USER is not set, please use -u')
784
785 options.user = username(options.user)
786
787 if not options.begin:
788 if options.last_quarter:
789 begin, end = quarter_begin, quarter_end
790 elif options.this_year:
791 begin, end = get_year_of(datetime.today())
792 elif options.week_of:
793 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
794 else:
795 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
796 else:
797 begin = datetime.strptime(options.begin, '%m/%d/%y')
798 if options.end:
799 end = datetime.strptime(options.end, '%m/%d/%y')
800 else:
801 end = datetime.today()
802 options.begin, options.end = begin, end
803
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000804 if options.markdown:
805 options.output_format = ' * [{title}]({url})'
806 options.output_format_heading = '### {heading} ###'
807
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000808 print 'Searching for activity by %s' % options.user
809 print 'Using range %s to %s' % (options.begin, options.end)
810
811 my_activity = MyActivity(options)
812
813 if not (options.changes or options.reviews or options.issues):
814 options.changes = True
815 options.issues = True
816 options.reviews = True
817
818 # First do any required authentication so none of the user interaction has to
819 # wait for actual work.
820 if options.changes:
821 my_activity.auth_for_changes()
822 if options.reviews:
823 my_activity.auth_for_reviews()
824 if options.issues:
825 my_activity.auth_for_issues()
826
827 print 'Looking up activity.....'
828
829 if options.changes:
830 my_activity.get_changes()
831 if options.reviews:
832 my_activity.get_reviews()
833 if options.issues:
834 my_activity.get_issues()
835
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000836 print '\n\n\n'
837
838 my_activity.print_changes()
839 my_activity.print_reviews()
840 my_activity.print_issues()
841 return 0
842
843
844if __name__ == '__main__':
845 sys.exit(main())