blob: 720d9255cd308c58fb0b6c9291aea28ef9189317 [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 },
kjellander@chromium.org363c10c2015-03-16 10:12:22 +000079 {
80 'url': 'webrtc-codereview.appspot.com',
81 'shorturl': 'go/rtcrev',
82 'supports_owner_modified_query': True,
83 'requires_auth': False,
84 'email_domain': 'webrtc.org',
85 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000086]
87
88gerrit_instances = [
89 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000090 'url': 'chromium-review.googlesource.com',
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +000091 'shorturl': 'crosreview.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000092 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000093 {
94 'url': 'chrome-internal-review.googlesource.com',
95 'shorturl': 'crosreview.com/i',
96 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000097 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000098 'host': 'gerrit.chromium.org',
99 'port': 29418,
100 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000101]
102
103google_code_projects = [
104 {
deymo@chromium.org69bf3ad2015-02-02 22:19:46 +0000105 'name': 'brillo',
106 'shorturl': 'brbug.com',
107 },
108 {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000109 'name': 'chromium',
110 'shorturl': 'crbug.com',
111 },
112 {
113 'name': 'chromium-os',
deymo@chromium.orgc840e212013-02-13 20:40:22 +0000114 'shorturl': 'crosbug.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000115 },
116 {
117 'name': 'chrome-os-partner',
118 },
119 {
120 'name': 'google-breakpad',
121 },
122 {
123 'name': 'gyp',
enne@chromium.orgf01fad32012-11-26 18:09:38 +0000124 },
125 {
126 'name': 'skia',
127 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000128]
129
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000130# Uses ClientLogin to authenticate the user for Google Code issue trackers.
131def get_auth_token(email):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000132 # KeyringCreds will use the system keyring on the first try, and prompt for
133 # a password on the next ones.
134 creds = upload.KeyringCreds('code.google.com', 'code.google.com', email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000135 for _ in xrange(3):
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000136 email, password = creds.GetUserCredentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000137 url = 'https://www.google.com/accounts/ClientLogin'
138 data = urllib.urlencode({
139 'Email': email,
140 'Passwd': password,
141 'service': 'code',
142 'source': 'chrome-my-activity',
143 'accountType': 'GOOGLE',
144 })
145 req = urllib2.Request(url, data=data, headers={'Accept': 'text/plain'})
146 try:
147 response = urllib2.urlopen(req)
148 response_body = response.read()
149 response_dict = dict(x.split('=')
150 for x in response_body.split('\n') if x)
151 return response_dict['Auth']
152 except urllib2.HTTPError, e:
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000153 print e
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000154
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000155 print 'Unable to authenticate to code.google.com.'
156 print 'Some issues may be missing.'
157 return None
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000158
159
160def username(email):
161 """Keeps the username of an email address."""
162 return email and email.split('@', 1)[0]
163
164
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000165def datetime_to_midnight(date):
166 return date - timedelta(hours=date.hour, minutes=date.minute,
167 seconds=date.second, microseconds=date.microsecond)
168
169
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000170def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000171 begin = (datetime_to_midnight(date) -
172 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000173 return begin, begin + relativedelta(months=3)
174
175
176def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000177 begin = (datetime_to_midnight(date) -
178 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000179 return begin, begin + relativedelta(years=1)
180
181
182def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000183 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000184 return begin, begin + timedelta(days=7)
185
186
187def get_yes_or_no(msg):
188 while True:
189 response = raw_input(msg + ' yes/no [no] ')
190 if response == 'y' or response == 'yes':
191 return True
192 elif not response or response == 'n' or response == 'no':
193 return False
194
195
deymo@chromium.org6c039202013-09-12 12:28:12 +0000196def datetime_from_gerrit(date_string):
197 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
198
199
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000200def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000201 try:
202 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
203 except ValueError:
204 # Sometimes rietveld returns a value without the milliseconds part, so we
205 # attempt to parse those cases as well.
206 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000207
208
209def datetime_from_google_code(date_string):
210 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
211
212
213class MyActivity(object):
214 def __init__(self, options):
215 self.options = options
216 self.modified_after = options.begin
217 self.modified_before = options.end
218 self.user = options.user
219 self.changes = []
220 self.reviews = []
221 self.issues = []
222 self.check_cookies()
223 self.google_code_auth_token = None
224
225 # Check the codereview cookie jar to determine which Rietveld instances to
226 # authenticate to.
227 def check_cookies(self):
228 cookie_file = os.path.expanduser('~/.codereview_upload_cookies')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000229 if not os.path.exists(cookie_file):
deymo@chromium.org8aee4862013-11-13 19:33:27 +0000230 print 'No Rietveld cookie file found.'
231 cookie_jar = []
232 else:
233 cookie_jar = cookielib.MozillaCookieJar(cookie_file)
234 try:
235 cookie_jar.load()
236 print 'Found cookie file: %s' % cookie_file
237 except (cookielib.LoadError, IOError):
238 print 'Error loading Rietveld cookie file: %s' % cookie_file
239 cookie_jar = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000240
241 filtered_instances = []
242
243 def has_cookie(instance):
244 for cookie in cookie_jar:
245 if cookie.name == 'SACSID' and cookie.domain == instance['url']:
246 return True
247 if self.options.auth:
248 return get_yes_or_no('No cookie found for %s. Authorize for this '
249 'instance? (may require application-specific '
250 'password)' % instance['url'])
251 filtered_instances.append(instance)
252 return False
253
254 for instance in rietveld_instances:
255 instance['auth'] = has_cookie(instance)
256
257 if filtered_instances:
258 print ('No cookie found for the following Rietveld instance%s:' %
259 ('s' if len(filtered_instances) > 1 else ''))
260 for instance in filtered_instances:
261 print '\t' + instance['url']
262 print 'Use --auth if you would like to authenticate to them.\n'
263
264 def rietveld_search(self, instance, owner=None, reviewer=None):
265 if instance['requires_auth'] and not instance['auth']:
266 return []
267
268
269 email = None if instance['auth'] else ''
270 remote = rietveld.Rietveld('https://' + instance['url'], email, None)
271
272 # See def search() in rietveld.py to see all the filters you can use.
273 query_modified_after = None
274
275 if instance['supports_owner_modified_query']:
276 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
277
278 # Rietveld does not allow search by both created_before and modified_after.
279 # (And some instances don't allow search by both owner and modified_after)
280 owner_email = None
281 reviewer_email = None
282 if owner:
283 owner_email = owner + '@' + instance['email_domain']
284 if reviewer:
285 reviewer_email = reviewer + '@' + instance['email_domain']
286 issues = remote.search(
287 owner=owner_email,
288 reviewer=reviewer_email,
289 modified_after=query_modified_after,
290 with_messages=True)
291
292 issues = filter(
293 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
294 issues)
295 issues = filter(
296 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
297 issues)
298
299 should_filter_by_user = True
300 issues = map(partial(self.process_rietveld_issue, instance), issues)
301 issues = filter(
302 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
303 issues)
304 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
305
306 return issues
307
308 def process_rietveld_issue(self, instance, issue):
309 ret = {}
310 ret['owner'] = issue['owner_email']
311 ret['author'] = ret['owner']
312
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000313 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000314
315 shorturl = instance['url']
316 if 'shorturl' in instance:
317 shorturl = instance['shorturl']
318
319 ret['review_url'] = 'http://%s/%d' % (shorturl, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000320
321 # Rietveld sometimes has '\r\n' instead of '\n'.
322 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000323
324 ret['modified'] = datetime_from_rietveld(issue['modified'])
325 ret['created'] = datetime_from_rietveld(issue['created'])
326 ret['replies'] = self.process_rietveld_replies(issue['messages'])
327
328 return ret
329
330 @staticmethod
331 def process_rietveld_replies(replies):
332 ret = []
333 for reply in replies:
334 r = {}
335 r['author'] = reply['sender']
336 r['created'] = datetime_from_rietveld(reply['date'])
337 r['content'] = ''
338 ret.append(r)
339 return ret
340
deymo@chromium.org6c039202013-09-12 12:28:12 +0000341 @staticmethod
342 def gerrit_changes_over_ssh(instance, filters):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000343 # See https://review.openstack.org/Documentation/cmd-query.html
344 # Gerrit doesn't allow filtering by created time, only modified time.
deymo@chromium.org6c039202013-09-12 12:28:12 +0000345 gquery_cmd = ['ssh', '-p', str(instance['port']), instance['host'],
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000346 'gerrit', 'query',
347 '--format', 'JSON',
348 '--comments',
deymo@chromium.org6c039202013-09-12 12:28:12 +0000349 '--'] + filters
350 (stdout, _) = subprocess.Popen(gquery_cmd, stdout=subprocess.PIPE,
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000351 stderr=subprocess.PIPE).communicate()
deymo@chromium.org6c039202013-09-12 12:28:12 +0000352 # Drop the last line of the output with the stats.
353 issues = stdout.splitlines()[:-1]
354 return map(json.loads, issues)
355
356 @staticmethod
357 def gerrit_changes_over_rest(instance, filters):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000358 # Convert the "key:value" filter to a dictionary.
359 req = dict(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000360 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000361 # Instantiate the generator to force all the requests now and catch the
362 # errors here.
363 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
364 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS']))
365 except gerrit_util.GerritError, e:
366 print 'ERROR: Looking up %r: %s' % (instance['url'], e)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000367 return []
368
deymo@chromium.org6c039202013-09-12 12:28:12 +0000369 def gerrit_search(self, instance, owner=None, reviewer=None):
370 max_age = datetime.today() - self.modified_after
371 max_age = max_age.days * 24 * 3600 + max_age.seconds
372 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
373 filters = ['-age:%ss' % max_age, user_filter]
374
375 # Determine the gerrit interface to use: SSH or REST API:
376 if 'host' in instance:
377 issues = self.gerrit_changes_over_ssh(instance, filters)
378 issues = [self.process_gerrit_ssh_issue(instance, issue)
379 for issue in issues]
380 elif 'url' in instance:
381 issues = self.gerrit_changes_over_rest(instance, filters)
382 issues = [self.process_gerrit_rest_issue(instance, issue)
383 for issue in issues]
384 else:
385 raise Exception('Invalid gerrit_instances configuration.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000386
387 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000388 issues = filter(self.filter_issue, issues)
389 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
390
391 return issues
392
deymo@chromium.org6c039202013-09-12 12:28:12 +0000393 def process_gerrit_ssh_issue(self, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000394 ret = {}
395 ret['review_url'] = issue['url']
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +0000396 if 'shorturl' in instance:
397 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
398 issue['number'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000399 ret['header'] = issue['subject']
400 ret['owner'] = issue['owner']['email']
401 ret['author'] = ret['owner']
402 ret['created'] = datetime.fromtimestamp(issue['createdOn'])
403 ret['modified'] = datetime.fromtimestamp(issue['lastUpdated'])
404 if 'comments' in issue:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000405 ret['replies'] = self.process_gerrit_ssh_issue_replies(issue['comments'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000406 else:
407 ret['replies'] = []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000408 ret['reviewers'] = set(r['author'] for r in ret['replies'])
409 ret['reviewers'].discard(ret['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000410 return ret
411
412 @staticmethod
deymo@chromium.org6c039202013-09-12 12:28:12 +0000413 def process_gerrit_ssh_issue_replies(replies):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000414 ret = []
415 replies = filter(lambda r: 'email' in r['reviewer'], replies)
416 for reply in replies:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000417 ret.append({
418 'author': reply['reviewer']['email'],
419 'created': datetime.fromtimestamp(reply['timestamp']),
420 'content': '',
421 })
422 return ret
423
424 def process_gerrit_rest_issue(self, instance, issue):
425 ret = {}
426 ret['review_url'] = 'https://%s/%s' % (instance['url'], issue['_number'])
427 if 'shorturl' in instance:
428 # TODO(deymo): Move this short link to https once crosreview.com supports
429 # it.
430 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
431 issue['_number'])
432 ret['header'] = issue['subject']
433 ret['owner'] = issue['owner']['email']
434 ret['author'] = ret['owner']
435 ret['created'] = datetime_from_gerrit(issue['created'])
436 ret['modified'] = datetime_from_gerrit(issue['updated'])
437 if 'messages' in issue:
438 ret['replies'] = self.process_gerrit_rest_issue_replies(issue['messages'])
439 else:
440 ret['replies'] = []
441 ret['reviewers'] = set(r['author'] for r in ret['replies'])
442 ret['reviewers'].discard(ret['author'])
443 return ret
444
445 @staticmethod
446 def process_gerrit_rest_issue_replies(replies):
447 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000448 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
449 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000450 for reply in replies:
451 ret.append({
452 'author': reply['author']['email'],
453 'created': datetime_from_gerrit(reply['date']),
454 'content': reply['message'],
455 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000456 return ret
457
458 def google_code_issue_search(self, instance):
459 time_format = '%Y-%m-%dT%T'
460 # See http://code.google.com/p/support/wiki/IssueTrackerAPI
461 # q=<owner>@chromium.org does a full text search for <owner>@chromium.org.
462 # This will accept the issue if owner is the owner or in the cc list. Might
463 # have some false positives, though.
464
465 # Don't filter normally on modified_before because it can filter out things
466 # that were modified in the time period and then modified again after it.
467 gcode_url = ('https://code.google.com/feeds/issues/p/%s/issues/full' %
468 instance['name'])
469
470 gcode_data = urllib.urlencode({
471 'alt': 'json',
472 'max-results': '100000',
473 'q': '%s' % self.user,
474 'published-max': self.modified_before.strftime(time_format),
475 'updated-min': self.modified_after.strftime(time_format),
476 })
477
478 opener = urllib2.build_opener()
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000479 if self.google_code_auth_token:
480 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
481 self.google_code_auth_token)]
482 gcode_json = None
483 try:
484 gcode_get = opener.open(gcode_url + '?' + gcode_data)
485 gcode_json = json.load(gcode_get)
486 gcode_get.close()
487 except urllib2.HTTPError, _:
488 print 'Unable to access ' + instance['name'] + ' issue tracker.'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000489
cjhopman@chromium.org3365f2d2012-11-01 18:53:13 +0000490 if not gcode_json or 'entry' not in gcode_json['feed']:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000491 return []
492
493 issues = gcode_json['feed']['entry']
494 issues = map(partial(self.process_google_code_issue, instance), issues)
495 issues = filter(self.filter_issue, issues)
496 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
497 return issues
498
499 def process_google_code_issue(self, project, issue):
500 ret = {}
501 ret['created'] = datetime_from_google_code(issue['published']['$t'])
502 ret['modified'] = datetime_from_google_code(issue['updated']['$t'])
503
504 ret['owner'] = ''
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000505 if 'issues$owner' in issue:
506 ret['owner'] = issue['issues$owner']['issues$username']['$t']
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000507 ret['author'] = issue['author'][0]['name']['$t']
508
509 if 'shorturl' in project:
510 issue_id = issue['id']['$t']
511 issue_id = issue_id[issue_id.rfind('/') + 1:]
512 ret['url'] = 'http://%s/%d' % (project['shorturl'], int(issue_id))
513 else:
514 issue_url = issue['link'][1]
515 if issue_url['rel'] != 'alternate':
516 raise RuntimeError
517 ret['url'] = issue_url['href']
518 ret['header'] = issue['title']['$t']
519
520 ret['replies'] = self.get_google_code_issue_replies(issue)
521 return ret
522
523 def get_google_code_issue_replies(self, issue):
524 """Get all the comments on the issue."""
525 replies_url = issue['link'][0]
526 if replies_url['rel'] != 'replies':
527 raise RuntimeError
528
529 replies_data = urllib.urlencode({
530 'alt': 'json',
531 'fields': 'entry(published,author,content)',
532 })
533
534 opener = urllib2.build_opener()
535 opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
536 self.google_code_auth_token)]
537 try:
538 replies_get = opener.open(replies_url['href'] + '?' + replies_data)
539 except urllib2.HTTPError, _:
540 return []
541
542 replies_json = json.load(replies_get)
543 replies_get.close()
544 return self.process_google_code_issue_replies(replies_json)
545
546 @staticmethod
547 def process_google_code_issue_replies(replies):
548 if 'entry' not in replies['feed']:
549 return []
550
551 ret = []
552 for entry in replies['feed']['entry']:
553 e = {}
554 e['created'] = datetime_from_google_code(entry['published']['$t'])
555 e['content'] = entry['content']['$t']
556 e['author'] = entry['author'][0]['name']['$t']
557 ret.append(e)
558 return ret
559
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000560 def print_heading(self, heading):
561 print
562 print self.options.output_format_heading.format(heading=heading)
563
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000564 def print_change(self, change):
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000565 optional_values = {
566 'reviewers': ', '.join(change['reviewers'])
567 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000568 self.print_generic(self.options.output_format,
569 self.options.output_format_changes,
570 change['header'],
571 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000572 change['author'],
573 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000574
575 def print_issue(self, issue):
576 optional_values = {
577 'owner': issue['owner'],
578 }
579 self.print_generic(self.options.output_format,
580 self.options.output_format_issues,
581 issue['header'],
582 issue['url'],
583 issue['author'],
584 optional_values)
585
586 def print_review(self, review):
587 self.print_generic(self.options.output_format,
588 self.options.output_format_reviews,
589 review['header'],
590 review['review_url'],
591 review['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000592
593 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000594 def print_generic(default_fmt, specific_fmt,
595 title, url, author,
596 optional_values=None):
597 output_format = specific_fmt if specific_fmt is not None else default_fmt
598 output_format = unicode(output_format)
599 required_values = {
600 'title': title,
601 'url': url,
602 'author': author,
603 }
604 # Merge required and optional values.
605 if optional_values is not None:
606 values = dict(required_values.items() + optional_values.items())
607 else:
608 values = required_values
609 print output_format.format(**values)
610
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000611
612 def filter_issue(self, issue, should_filter_by_user=True):
613 def maybe_filter_username(email):
614 return not should_filter_by_user or username(email) == self.user
615 if (maybe_filter_username(issue['author']) and
616 self.filter_modified(issue['created'])):
617 return True
618 if (maybe_filter_username(issue['owner']) and
619 (self.filter_modified(issue['created']) or
620 self.filter_modified(issue['modified']))):
621 return True
622 for reply in issue['replies']:
623 if self.filter_modified(reply['created']):
624 if not should_filter_by_user:
625 break
626 if (username(reply['author']) == self.user
627 or (self.user + '@') in reply['content']):
628 break
629 else:
630 return False
631 return True
632
633 def filter_modified(self, modified):
634 return self.modified_after < modified and modified < self.modified_before
635
636 def auth_for_changes(self):
637 #TODO(cjhopman): Move authentication check for getting changes here.
638 pass
639
640 def auth_for_reviews(self):
641 # Reviews use all the same instances as changes so no authentication is
642 # required.
643 pass
644
645 def auth_for_issues(self):
646 self.google_code_auth_token = (
647 get_auth_token(self.options.local_user + '@chromium.org'))
648
649 def get_changes(self):
650 for instance in rietveld_instances:
651 self.changes += self.rietveld_search(instance, owner=self.user)
652
653 for instance in gerrit_instances:
654 self.changes += self.gerrit_search(instance, owner=self.user)
655
656 def print_changes(self):
657 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000658 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000659 for change in self.changes:
660 self.print_change(change)
661
662 def get_reviews(self):
663 for instance in rietveld_instances:
664 self.reviews += self.rietveld_search(instance, reviewer=self.user)
665
666 for instance in gerrit_instances:
667 reviews = self.gerrit_search(instance, reviewer=self.user)
668 reviews = filter(lambda r: not username(r['owner']) == self.user, reviews)
669 self.reviews += reviews
670
671 def print_reviews(self):
672 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000673 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000674 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000675 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000676
677 def get_issues(self):
678 for project in google_code_projects:
679 self.issues += self.google_code_issue_search(project)
680
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000681 def print_issues(self):
682 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000683 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000684 for issue in self.issues:
685 self.print_issue(issue)
686
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000687 def print_activity(self):
688 self.print_changes()
689 self.print_reviews()
690 self.print_issues()
691
692
693def main():
694 # Silence upload.py.
695 rietveld.upload.verbosity = 0
696
697 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
698 parser.add_option(
699 '-u', '--user', metavar='<email>',
700 default=os.environ.get('USER'),
701 help='Filter on user, default=%default')
702 parser.add_option(
703 '-b', '--begin', metavar='<date>',
704 help='Filter issues created after the date')
705 parser.add_option(
706 '-e', '--end', metavar='<date>',
707 help='Filter issues created before the date')
708 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
709 relativedelta(months=2))
710 parser.add_option(
711 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000712 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000713 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
714 parser.add_option(
715 '-Y', '--this_year', action='store_true',
716 help='Use this year\'s dates')
717 parser.add_option(
718 '-w', '--week_of', metavar='<date>',
719 help='Show issues for week of the date')
720 parser.add_option(
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000721 '-W', '--last_week', action='store_true',
722 help='Show last week\'s issues')
723 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000724 '-a', '--auth',
725 action='store_true',
726 help='Ask to authenticate for instances with no auth cookie')
727
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000728 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000729 'By default, all activity will be looked up and '
730 'printed. If any of these are specified, only '
731 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000732 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000733 '-c', '--changes',
734 action='store_true',
735 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000736 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000737 '-i', '--issues',
738 action='store_true',
739 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000740 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000741 '-r', '--reviews',
742 action='store_true',
743 help='Show reviews.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000744 parser.add_option_group(activity_types_group)
745
746 output_format_group = optparse.OptionGroup(parser, 'Output Format',
747 'By default, all activity will be printed in the '
748 'following format: {url} {title}. This can be '
749 'changed for either all activity types or '
750 'individually for each activity type. The format '
751 'is defined as documented for '
752 'string.format(...). The variables available for '
753 'all activity types are url, title and author. '
754 'Format options for specific activity types will '
755 'override the generic format.')
756 output_format_group.add_option(
757 '-f', '--output-format', metavar='<format>',
758 default=u'{url} {title}',
759 help='Specifies the format to use when printing all your activity.')
760 output_format_group.add_option(
761 '--output-format-changes', metavar='<format>',
762 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000763 help='Specifies the format to use when printing changes. Supports the '
764 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000765 output_format_group.add_option(
766 '--output-format-issues', metavar='<format>',
767 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000768 help='Specifies the format to use when printing issues. Supports the '
769 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000770 output_format_group.add_option(
771 '--output-format-reviews', metavar='<format>',
772 default=None,
773 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000774 output_format_group.add_option(
775 '--output-format-heading', metavar='<format>',
776 default=u'{heading}:',
777 help='Specifies the format to use when printing headings.')
778 output_format_group.add_option(
779 '-m', '--markdown', action='store_true',
780 help='Use markdown-friendly output (overrides --output-format '
781 'and --output-format-heading)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000782 parser.add_option_group(output_format_group)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000783
784 # Remove description formatting
785 parser.format_description = (
786 lambda _: parser.description) # pylint: disable=E1101
787
788 options, args = parser.parse_args()
789 options.local_user = os.environ.get('USER')
790 if args:
791 parser.error('Args unsupported')
792 if not options.user:
793 parser.error('USER is not set, please use -u')
794
795 options.user = username(options.user)
796
797 if not options.begin:
798 if options.last_quarter:
799 begin, end = quarter_begin, quarter_end
800 elif options.this_year:
801 begin, end = get_year_of(datetime.today())
802 elif options.week_of:
803 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000804 elif options.last_week:
805 begin, end = (get_week_of(datetime.today() - timedelta(days=7)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000806 else:
807 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
808 else:
809 begin = datetime.strptime(options.begin, '%m/%d/%y')
810 if options.end:
811 end = datetime.strptime(options.end, '%m/%d/%y')
812 else:
813 end = datetime.today()
814 options.begin, options.end = begin, end
815
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000816 if options.markdown:
817 options.output_format = ' * [{title}]({url})'
818 options.output_format_heading = '### {heading} ###'
819
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000820 print 'Searching for activity by %s' % options.user
821 print 'Using range %s to %s' % (options.begin, options.end)
822
823 my_activity = MyActivity(options)
824
825 if not (options.changes or options.reviews or options.issues):
826 options.changes = True
827 options.issues = True
828 options.reviews = True
829
830 # First do any required authentication so none of the user interaction has to
831 # wait for actual work.
832 if options.changes:
833 my_activity.auth_for_changes()
834 if options.reviews:
835 my_activity.auth_for_reviews()
836 if options.issues:
837 my_activity.auth_for_issues()
838
839 print 'Looking up activity.....'
840
841 if options.changes:
842 my_activity.get_changes()
843 if options.reviews:
844 my_activity.get_reviews()
845 if options.issues:
846 my_activity.get_issues()
847
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000848 print '\n\n\n'
849
850 my_activity.print_changes()
851 my_activity.print_reviews()
852 my_activity.print_issues()
853 return 0
854
855
856if __name__ == '__main__':
sbc@chromium.org013731e2015-02-26 18:28:43 +0000857 try:
858 sys.exit(main())
859 except KeyboardInterrupt:
860 sys.stderr.write('interrupted\n')
861 sys.exit(1)