blob: 58e217681c165a89e9b4968cf4397a186fb0be6b [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 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000094]
95
96google_code_projects = [
97 {
98 'name': 'chromium',
99 'shorturl': 'crbug.com',
100 },
101 {
102 'name': 'chromium-os',
deymo@chromium.orgc840e212013-02-13 20:40:22 +0000103 'shorturl': 'crosbug.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000104 },
105 {
106 'name': 'chrome-os-partner',
107 },
108 {
109 'name': 'google-breakpad',
110 },
111 {
112 'name': 'gyp',
enne@chromium.orgf01fad32012-11-26 18:09:38 +0000113 },
114 {
115 'name': 'skia',
116 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000117]
118
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000119# Uses ClientLogin to authenticate the user for Google Code issue trackers.
120def get_auth_token(email):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000121 # KeyringCreds will use the system keyring on the first try, and prompt for
122 # a password on the next ones.
123 creds = upload.KeyringCreds('code.google.com', 'code.google.com', email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000124 for _ in xrange(3):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000125 email, password = creds.GetUserCredentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000126 url = 'https://www.google.com/accounts/ClientLogin'
127 data = urllib.urlencode({
128 'Email': email,
129 'Passwd': password,
130 'service': 'code',
131 'source': 'chrome-my-activity',
132 'accountType': 'GOOGLE',
133 })
134 req = urllib2.Request(url, data=data, headers={'Accept': 'text/plain'})
135 try:
136 response = urllib2.urlopen(req)
137 response_body = response.read()
138 response_dict = dict(x.split('=')
139 for x in response_body.split('\n') if x)
140 return response_dict['Auth']
141 except urllib2.HTTPError, e:
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000142 print e
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000143
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000144 print 'Unable to authenticate to code.google.com.'
145 print 'Some issues may be missing.'
146 return None
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000147
148
149def username(email):
150 """Keeps the username of an email address."""
151 return email and email.split('@', 1)[0]
152
153
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000154def datetime_to_midnight(date):
155 return date - timedelta(hours=date.hour, minutes=date.minute,
156 seconds=date.second, microseconds=date.microsecond)
157
158
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000159def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000160 begin = (datetime_to_midnight(date) -
161 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000162 return begin, begin + relativedelta(months=3)
163
164
165def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000166 begin = (datetime_to_midnight(date) -
167 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000168 return begin, begin + relativedelta(years=1)
169
170
171def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000172 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000173 return begin, begin + timedelta(days=7)
174
175
176def get_yes_or_no(msg):
177 while True:
178 response = raw_input(msg + ' yes/no [no] ')
179 if response == 'y' or response == 'yes':
180 return True
181 elif not response or response == 'n' or response == 'no':
182 return False
183
184
deymo@chromium.org6c039202013-09-12 12:28:12 +0000185def datetime_from_gerrit(date_string):
186 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
187
188
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000189def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000190 try:
191 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
192 except ValueError:
193 # Sometimes rietveld returns a value without the milliseconds part, so we
194 # attempt to parse those cases as well.
195 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000196
197
198def datetime_from_google_code(date_string):
199 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
200
201
202class MyActivity(object):
203 def __init__(self, options):
204 self.options = options
205 self.modified_after = options.begin
206 self.modified_before = options.end
207 self.user = options.user
208 self.changes = []
209 self.reviews = []
210 self.issues = []
211 self.check_cookies()
212 self.google_code_auth_token = None
213
214 # Check the codereview cookie jar to determine which Rietveld instances to
215 # authenticate to.
216 def check_cookies(self):
217 cookie_file = os.path.expanduser('~/.codereview_upload_cookies')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000218 if not os.path.exists(cookie_file):
deymo@chromium.org8aee4862013-11-13 19:33:27 +0000219 print 'No Rietveld cookie file found.'
220 cookie_jar = []
221 else:
222 cookie_jar = cookielib.MozillaCookieJar(cookie_file)
223 try:
224 cookie_jar.load()
225 print 'Found cookie file: %s' % cookie_file
226 except (cookielib.LoadError, IOError):
227 print 'Error loading Rietveld cookie file: %s' % cookie_file
228 cookie_jar = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000229
230 filtered_instances = []
231
232 def has_cookie(instance):
233 for cookie in cookie_jar:
234 if cookie.name == 'SACSID' and cookie.domain == instance['url']:
235 return True
236 if self.options.auth:
237 return get_yes_or_no('No cookie found for %s. Authorize for this '
238 'instance? (may require application-specific '
239 'password)' % instance['url'])
240 filtered_instances.append(instance)
241 return False
242
243 for instance in rietveld_instances:
244 instance['auth'] = has_cookie(instance)
245
246 if filtered_instances:
247 print ('No cookie found for the following Rietveld instance%s:' %
248 ('s' if len(filtered_instances) > 1 else ''))
249 for instance in filtered_instances:
250 print '\t' + instance['url']
251 print 'Use --auth if you would like to authenticate to them.\n'
252
253 def rietveld_search(self, instance, owner=None, reviewer=None):
254 if instance['requires_auth'] and not instance['auth']:
255 return []
256
257
258 email = None if instance['auth'] else ''
259 remote = rietveld.Rietveld('https://' + instance['url'], email, None)
260
261 # See def search() in rietveld.py to see all the filters you can use.
262 query_modified_after = None
263
264 if instance['supports_owner_modified_query']:
265 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
266
267 # Rietveld does not allow search by both created_before and modified_after.
268 # (And some instances don't allow search by both owner and modified_after)
269 owner_email = None
270 reviewer_email = None
271 if owner:
272 owner_email = owner + '@' + instance['email_domain']
273 if reviewer:
274 reviewer_email = reviewer + '@' + instance['email_domain']
275 issues = remote.search(
276 owner=owner_email,
277 reviewer=reviewer_email,
278 modified_after=query_modified_after,
279 with_messages=True)
280
281 issues = filter(
282 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
283 issues)
284 issues = filter(
285 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
286 issues)
287
288 should_filter_by_user = True
289 issues = map(partial(self.process_rietveld_issue, instance), issues)
290 issues = filter(
291 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
292 issues)
293 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
294
295 return issues
296
297 def process_rietveld_issue(self, instance, issue):
298 ret = {}
299 ret['owner'] = issue['owner_email']
300 ret['author'] = ret['owner']
301
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000302 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000303
304 shorturl = instance['url']
305 if 'shorturl' in instance:
306 shorturl = instance['shorturl']
307
308 ret['review_url'] = 'http://%s/%d' % (shorturl, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000309
310 # Rietveld sometimes has '\r\n' instead of '\n'.
311 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000312
313 ret['modified'] = datetime_from_rietveld(issue['modified'])
314 ret['created'] = datetime_from_rietveld(issue['created'])
315 ret['replies'] = self.process_rietveld_replies(issue['messages'])
316
317 return ret
318
319 @staticmethod
320 def process_rietveld_replies(replies):
321 ret = []
322 for reply in replies:
323 r = {}
324 r['author'] = reply['sender']
325 r['created'] = datetime_from_rietveld(reply['date'])
326 r['content'] = ''
327 ret.append(r)
328 return ret
329
deymo@chromium.org6c039202013-09-12 12:28:12 +0000330 @staticmethod
331 def gerrit_changes_over_ssh(instance, filters):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000332 # See https://review.openstack.org/Documentation/cmd-query.html
333 # Gerrit doesn't allow filtering by created time, only modified time.
deymo@chromium.org6c039202013-09-12 12:28:12 +0000334 gquery_cmd = ['ssh', '-p', str(instance['port']), instance['host'],
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000335 'gerrit', 'query',
336 '--format', 'JSON',
337 '--comments',
deymo@chromium.org6c039202013-09-12 12:28:12 +0000338 '--'] + filters
339 (stdout, _) = subprocess.Popen(gquery_cmd, stdout=subprocess.PIPE,
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000340 stderr=subprocess.PIPE).communicate()
deymo@chromium.org6c039202013-09-12 12:28:12 +0000341 # Drop the last line of the output with the stats.
342 issues = stdout.splitlines()[:-1]
343 return map(json.loads, issues)
344
345 @staticmethod
346 def gerrit_changes_over_rest(instance, filters):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000347 # Convert the "key:value" filter to a dictionary.
348 req = dict(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000349 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000350 # Instantiate the generator to force all the requests now and catch the
351 # errors here.
352 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
353 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS']))
354 except gerrit_util.GerritError, e:
355 print 'ERROR: Looking up %r: %s' % (instance['url'], e)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000356 return []
357
deymo@chromium.org6c039202013-09-12 12:28:12 +0000358 def gerrit_search(self, instance, owner=None, reviewer=None):
359 max_age = datetime.today() - self.modified_after
360 max_age = max_age.days * 24 * 3600 + max_age.seconds
361 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
362 filters = ['-age:%ss' % max_age, user_filter]
363
364 # Determine the gerrit interface to use: SSH or REST API:
365 if 'host' in instance:
366 issues = self.gerrit_changes_over_ssh(instance, filters)
367 issues = [self.process_gerrit_ssh_issue(instance, issue)
368 for issue in issues]
369 elif 'url' in instance:
370 issues = self.gerrit_changes_over_rest(instance, filters)
371 issues = [self.process_gerrit_rest_issue(instance, issue)
372 for issue in issues]
373 else:
374 raise Exception('Invalid gerrit_instances configuration.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000375
376 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000377 issues = filter(self.filter_issue, issues)
378 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
379
380 return issues
381
deymo@chromium.org6c039202013-09-12 12:28:12 +0000382 def process_gerrit_ssh_issue(self, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000383 ret = {}
384 ret['review_url'] = issue['url']
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +0000385 if 'shorturl' in instance:
386 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
387 issue['number'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000388 ret['header'] = issue['subject']
389 ret['owner'] = issue['owner']['email']
390 ret['author'] = ret['owner']
391 ret['created'] = datetime.fromtimestamp(issue['createdOn'])
392 ret['modified'] = datetime.fromtimestamp(issue['lastUpdated'])
393 if 'comments' in issue:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000394 ret['replies'] = self.process_gerrit_ssh_issue_replies(issue['comments'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000395 else:
396 ret['replies'] = []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000397 ret['reviewers'] = set(r['author'] for r in ret['replies'])
398 ret['reviewers'].discard(ret['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000399 return ret
400
401 @staticmethod
deymo@chromium.org6c039202013-09-12 12:28:12 +0000402 def process_gerrit_ssh_issue_replies(replies):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000403 ret = []
404 replies = filter(lambda r: 'email' in r['reviewer'], replies)
405 for reply in replies:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000406 ret.append({
407 'author': reply['reviewer']['email'],
408 'created': datetime.fromtimestamp(reply['timestamp']),
409 'content': '',
410 })
411 return ret
412
413 def process_gerrit_rest_issue(self, instance, issue):
414 ret = {}
415 ret['review_url'] = 'https://%s/%s' % (instance['url'], issue['_number'])
416 if 'shorturl' in instance:
417 # TODO(deymo): Move this short link to https once crosreview.com supports
418 # it.
419 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
420 issue['_number'])
421 ret['header'] = issue['subject']
422 ret['owner'] = issue['owner']['email']
423 ret['author'] = ret['owner']
424 ret['created'] = datetime_from_gerrit(issue['created'])
425 ret['modified'] = datetime_from_gerrit(issue['updated'])
426 if 'messages' in issue:
427 ret['replies'] = self.process_gerrit_rest_issue_replies(issue['messages'])
428 else:
429 ret['replies'] = []
430 ret['reviewers'] = set(r['author'] for r in ret['replies'])
431 ret['reviewers'].discard(ret['author'])
432 return ret
433
434 @staticmethod
435 def process_gerrit_rest_issue_replies(replies):
436 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000437 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
438 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000439 for reply in replies:
440 ret.append({
441 'author': reply['author']['email'],
442 'created': datetime_from_gerrit(reply['date']),
443 'content': reply['message'],
444 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000445 return ret
446
447 def google_code_issue_search(self, instance):
448 time_format = '%Y-%m-%dT%T'
449 # See http://code.google.com/p/support/wiki/IssueTrackerAPI
450 # q=<owner>@chromium.org does a full text search for <owner>@chromium.org.
451 # This will accept the issue if owner is the owner or in the cc list. Might
452 # have some false positives, though.
453
454 # Don't filter normally on modified_before because it can filter out things
455 # that were modified in the time period and then modified again after it.
456 gcode_url = ('https://code.google.com/feeds/issues/p/%s/issues/full' %
457 instance['name'])
458
459 gcode_data = urllib.urlencode({
460 'alt': 'json',
461 'max-results': '100000',
462 'q': '%s' % self.user,
463 'published-max': self.modified_before.strftime(time_format),
464 'updated-min': self.modified_after.strftime(time_format),
465 })
466
467 opener = urllib2.build_opener()
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000468 if self.google_code_auth_token:
469 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
470 self.google_code_auth_token)]
471 gcode_json = None
472 try:
473 gcode_get = opener.open(gcode_url + '?' + gcode_data)
474 gcode_json = json.load(gcode_get)
475 gcode_get.close()
476 except urllib2.HTTPError, _:
477 print 'Unable to access ' + instance['name'] + ' issue tracker.'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000478
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000479 if not gcode_json or 'entry' not in gcode_json['feed']:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000480 return []
481
482 issues = gcode_json['feed']['entry']
483 issues = map(partial(self.process_google_code_issue, instance), issues)
484 issues = filter(self.filter_issue, issues)
485 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
486 return issues
487
488 def process_google_code_issue(self, project, issue):
489 ret = {}
490 ret['created'] = datetime_from_google_code(issue['published']['$t'])
491 ret['modified'] = datetime_from_google_code(issue['updated']['$t'])
492
493 ret['owner'] = ''
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000494 if 'issues$owner' in issue:
495 ret['owner'] = issue['issues$owner']['issues$username']['$t']
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000496 ret['author'] = issue['author'][0]['name']['$t']
497
498 if 'shorturl' in project:
499 issue_id = issue['id']['$t']
500 issue_id = issue_id[issue_id.rfind('/') + 1:]
501 ret['url'] = 'http://%s/%d' % (project['shorturl'], int(issue_id))
502 else:
503 issue_url = issue['link'][1]
504 if issue_url['rel'] != 'alternate':
505 raise RuntimeError
506 ret['url'] = issue_url['href']
507 ret['header'] = issue['title']['$t']
508
509 ret['replies'] = self.get_google_code_issue_replies(issue)
510 return ret
511
512 def get_google_code_issue_replies(self, issue):
513 """Get all the comments on the issue."""
514 replies_url = issue['link'][0]
515 if replies_url['rel'] != 'replies':
516 raise RuntimeError
517
518 replies_data = urllib.urlencode({
519 'alt': 'json',
520 'fields': 'entry(published,author,content)',
521 })
522
523 opener = urllib2.build_opener()
524 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
525 self.google_code_auth_token)]
526 try:
527 replies_get = opener.open(replies_url['href'] + '?' + replies_data)
528 except urllib2.HTTPError, _:
529 return []
530
531 replies_json = json.load(replies_get)
532 replies_get.close()
533 return self.process_google_code_issue_replies(replies_json)
534
535 @staticmethod
536 def process_google_code_issue_replies(replies):
537 if 'entry' not in replies['feed']:
538 return []
539
540 ret = []
541 for entry in replies['feed']['entry']:
542 e = {}
543 e['created'] = datetime_from_google_code(entry['published']['$t'])
544 e['content'] = entry['content']['$t']
545 e['author'] = entry['author'][0]['name']['$t']
546 ret.append(e)
547 return ret
548
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000549 def print_heading(self, heading):
550 print
551 print self.options.output_format_heading.format(heading=heading)
552
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000553 def print_change(self, change):
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000554 optional_values = {
555 'reviewers': ', '.join(change['reviewers'])
556 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000557 self.print_generic(self.options.output_format,
558 self.options.output_format_changes,
559 change['header'],
560 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000561 change['author'],
562 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000563
564 def print_issue(self, issue):
565 optional_values = {
566 'owner': issue['owner'],
567 }
568 self.print_generic(self.options.output_format,
569 self.options.output_format_issues,
570 issue['header'],
571 issue['url'],
572 issue['author'],
573 optional_values)
574
575 def print_review(self, review):
576 self.print_generic(self.options.output_format,
577 self.options.output_format_reviews,
578 review['header'],
579 review['review_url'],
580 review['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000581
582 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000583 def print_generic(default_fmt, specific_fmt,
584 title, url, author,
585 optional_values=None):
586 output_format = specific_fmt if specific_fmt is not None else default_fmt
587 output_format = unicode(output_format)
588 required_values = {
589 'title': title,
590 'url': url,
591 'author': author,
592 }
593 # Merge required and optional values.
594 if optional_values is not None:
595 values = dict(required_values.items() + optional_values.items())
596 else:
597 values = required_values
598 print output_format.format(**values)
599
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000600
601 def filter_issue(self, issue, should_filter_by_user=True):
602 def maybe_filter_username(email):
603 return not should_filter_by_user or username(email) == self.user
604 if (maybe_filter_username(issue['author']) and
605 self.filter_modified(issue['created'])):
606 return True
607 if (maybe_filter_username(issue['owner']) and
608 (self.filter_modified(issue['created']) or
609 self.filter_modified(issue['modified']))):
610 return True
611 for reply in issue['replies']:
612 if self.filter_modified(reply['created']):
613 if not should_filter_by_user:
614 break
615 if (username(reply['author']) == self.user
616 or (self.user + '@') in reply['content']):
617 break
618 else:
619 return False
620 return True
621
622 def filter_modified(self, modified):
623 return self.modified_after < modified and modified < self.modified_before
624
625 def auth_for_changes(self):
626 #TODO(cjhopman): Move authentication check for getting changes here.
627 pass
628
629 def auth_for_reviews(self):
630 # Reviews use all the same instances as changes so no authentication is
631 # required.
632 pass
633
634 def auth_for_issues(self):
635 self.google_code_auth_token = (
636 get_auth_token(self.options.local_user + '@chromium.org'))
637
638 def get_changes(self):
639 for instance in rietveld_instances:
640 self.changes += self.rietveld_search(instance, owner=self.user)
641
642 for instance in gerrit_instances:
643 self.changes += self.gerrit_search(instance, owner=self.user)
644
645 def print_changes(self):
646 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000647 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000648 for change in self.changes:
649 self.print_change(change)
650
651 def get_reviews(self):
652 for instance in rietveld_instances:
653 self.reviews += self.rietveld_search(instance, reviewer=self.user)
654
655 for instance in gerrit_instances:
656 reviews = self.gerrit_search(instance, reviewer=self.user)
657 reviews = filter(lambda r: not username(r['owner']) == self.user, reviews)
658 self.reviews += reviews
659
660 def print_reviews(self):
661 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000662 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000663 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000664 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000665
666 def get_issues(self):
667 for project in google_code_projects:
668 self.issues += self.google_code_issue_search(project)
669
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000670 def print_issues(self):
671 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000672 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000673 for issue in self.issues:
674 self.print_issue(issue)
675
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000676 def print_activity(self):
677 self.print_changes()
678 self.print_reviews()
679 self.print_issues()
680
681
682def main():
683 # Silence upload.py.
684 rietveld.upload.verbosity = 0
685
686 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
687 parser.add_option(
688 '-u', '--user', metavar='<email>',
689 default=os.environ.get('USER'),
690 help='Filter on user, default=%default')
691 parser.add_option(
692 '-b', '--begin', metavar='<date>',
693 help='Filter issues created after the date')
694 parser.add_option(
695 '-e', '--end', metavar='<date>',
696 help='Filter issues created before the date')
697 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
698 relativedelta(months=2))
699 parser.add_option(
700 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000701 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000702 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
703 parser.add_option(
704 '-Y', '--this_year', action='store_true',
705 help='Use this year\'s dates')
706 parser.add_option(
707 '-w', '--week_of', metavar='<date>',
708 help='Show issues for week of the date')
709 parser.add_option(
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000710 '-W', '--last_week', action='store_true',
711 help='Show last week\'s issues')
712 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000713 '-a', '--auth',
714 action='store_true',
715 help='Ask to authenticate for instances with no auth cookie')
716
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000717 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000718 'By default, all activity will be looked up and '
719 'printed. If any of these are specified, only '
720 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000721 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000722 '-c', '--changes',
723 action='store_true',
724 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000725 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000726 '-i', '--issues',
727 action='store_true',
728 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000729 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000730 '-r', '--reviews',
731 action='store_true',
732 help='Show reviews.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000733 parser.add_option_group(activity_types_group)
734
735 output_format_group = optparse.OptionGroup(parser, 'Output Format',
736 'By default, all activity will be printed in the '
737 'following format: {url} {title}. This can be '
738 'changed for either all activity types or '
739 'individually for each activity type. The format '
740 'is defined as documented for '
741 'string.format(...). The variables available for '
742 'all activity types are url, title and author. '
743 'Format options for specific activity types will '
744 'override the generic format.')
745 output_format_group.add_option(
746 '-f', '--output-format', metavar='<format>',
747 default=u'{url} {title}',
748 help='Specifies the format to use when printing all your activity.')
749 output_format_group.add_option(
750 '--output-format-changes', metavar='<format>',
751 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000752 help='Specifies the format to use when printing changes. Supports the '
753 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000754 output_format_group.add_option(
755 '--output-format-issues', metavar='<format>',
756 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000757 help='Specifies the format to use when printing issues. Supports the '
758 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000759 output_format_group.add_option(
760 '--output-format-reviews', metavar='<format>',
761 default=None,
762 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000763 output_format_group.add_option(
764 '--output-format-heading', metavar='<format>',
765 default=u'{heading}:',
766 help='Specifies the format to use when printing headings.')
767 output_format_group.add_option(
768 '-m', '--markdown', action='store_true',
769 help='Use markdown-friendly output (overrides --output-format '
770 'and --output-format-heading)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000771 parser.add_option_group(output_format_group)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000772
773 # Remove description formatting
774 parser.format_description = (
775 lambda _: parser.description) # pylint: disable=E1101
776
777 options, args = parser.parse_args()
778 options.local_user = os.environ.get('USER')
779 if args:
780 parser.error('Args unsupported')
781 if not options.user:
782 parser.error('USER is not set, please use -u')
783
784 options.user = username(options.user)
785
786 if not options.begin:
787 if options.last_quarter:
788 begin, end = quarter_begin, quarter_end
789 elif options.this_year:
790 begin, end = get_year_of(datetime.today())
791 elif options.week_of:
792 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000793 elif options.last_week:
794 begin, end = (get_week_of(datetime.today() - timedelta(days=7)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000795 else:
796 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
797 else:
798 begin = datetime.strptime(options.begin, '%m/%d/%y')
799 if options.end:
800 end = datetime.strptime(options.end, '%m/%d/%y')
801 else:
802 end = datetime.today()
803 options.begin, options.end = begin, end
804
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000805 if options.markdown:
806 options.output_format = ' * [{title}]({url})'
807 options.output_format_heading = '### {heading} ###'
808
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000809 print 'Searching for activity by %s' % options.user
810 print 'Using range %s to %s' % (options.begin, options.end)
811
812 my_activity = MyActivity(options)
813
814 if not (options.changes or options.reviews or options.issues):
815 options.changes = True
816 options.issues = True
817 options.reviews = True
818
819 # First do any required authentication so none of the user interaction has to
820 # wait for actual work.
821 if options.changes:
822 my_activity.auth_for_changes()
823 if options.reviews:
824 my_activity.auth_for_reviews()
825 if options.issues:
826 my_activity.auth_for_issues()
827
828 print 'Looking up activity.....'
829
830 if options.changes:
831 my_activity.get_changes()
832 if options.reviews:
833 my_activity.get_reviews()
834 if options.issues:
835 my_activity.get_issues()
836
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000837 print '\n\n\n'
838
839 my_activity.print_changes()
840 my_activity.print_reviews()
841 my_activity.print_issues()
842 return 0
843
844
845if __name__ == '__main__':
846 sys.exit(main())