blob: ecc52960fae5ee27397f577aabf2caf79e846a27 [file] [log] [blame]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001#!/usr/bin/env python
2# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Get stats about your activity.
7
8Example:
9 - my_activity.py for stats for the current week (last week on mondays).
10 - my_activity.py -Q for stats for last quarter.
11 - my_activity.py -Y for stats for this year.
12 - my_activity.py -b 4/5/12 for stats since 4/5/12.
13 - my_activity.py -b 4/5/12 -e 6/7/12 for stats between 4/5/12 and 6/7/12.
14"""
15
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000016# TODO(vadimsh): This script knows too much about ClientLogin and cookies. It
17# will stop to work on ~20 Apr 2015.
18
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000019# These services typically only provide a created time and a last modified time
20# for each item for general queries. This is not enough to determine if there
21# was activity in a given time period. So, we first query for all things created
22# before end and modified after begin. Then, we get the details of each item and
23# check those details to determine if there was activity in the given period.
24# This means that query time scales mostly with (today() - begin).
25
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000026from datetime import datetime
27from datetime import timedelta
28from functools import partial
29import json
Tobias Sargeantffb3c432017-03-08 14:09:14 +000030import logging
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000031import optparse
32import os
33import subprocess
Tobias Sargeantffb3c432017-03-08 14:09:14 +000034from string import Formatter
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000035import sys
36import urllib
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000037
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000038import auth
stevefung@chromium.org832d51e2015-05-27 12:52:51 +000039import fix_encoding
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000040import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000041import rietveld
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000042
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000043from third_party import httplib2
44
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000045try:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000046 import dateutil # pylint: disable=import-error
47 import dateutil.parser
48 from dateutil.relativedelta import relativedelta
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000049except ImportError:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000050 logging.error('python-dateutil package required')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000051 exit(1)
52
Tobias Sargeantffb3c432017-03-08 14:09:14 +000053
54class DefaultFormatter(Formatter):
55 def __init__(self, default = ''):
56 super(DefaultFormatter, self).__init__()
57 self.default = default
58
59 def get_value(self, key, args, kwds):
60 if isinstance(key, basestring) and key not in kwds:
61 return self.default
62 return Formatter.get_value(self, key, args, kwds)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000063
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000064rietveld_instances = [
65 {
66 'url': 'codereview.chromium.org',
67 'shorturl': 'crrev.com',
68 'supports_owner_modified_query': True,
69 'requires_auth': False,
70 'email_domain': 'chromium.org',
71 },
72 {
73 'url': 'chromereviews.googleplex.com',
74 'shorturl': 'go/chromerev',
75 'supports_owner_modified_query': True,
76 'requires_auth': True,
77 'email_domain': 'google.com',
78 },
79 {
80 'url': 'codereview.appspot.com',
81 'supports_owner_modified_query': True,
82 'requires_auth': False,
83 'email_domain': 'chromium.org',
84 },
85 {
86 'url': 'breakpad.appspot.com',
87 'supports_owner_modified_query': False,
88 'requires_auth': False,
89 'email_domain': 'chromium.org',
90 },
91]
92
93gerrit_instances = [
94 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000095 'url': 'chromium-review.googlesource.com',
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +000096 'shorturl': 'crosreview.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000097 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000098 {
99 'url': 'chrome-internal-review.googlesource.com',
100 'shorturl': 'crosreview.com/i',
101 },
deymo@chromium.org56dc57a2015-09-10 18:26:54 +0000102 {
103 'url': 'android-review.googlesource.com',
104 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000105]
106
107google_code_projects = [
108 {
109 'name': 'chromium',
110 'shorturl': 'crbug.com',
111 },
112 {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000113 '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 +0000123def username(email):
124 """Keeps the username of an email address."""
125 return email and email.split('@', 1)[0]
126
127
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000128def datetime_to_midnight(date):
129 return date - timedelta(hours=date.hour, minutes=date.minute,
130 seconds=date.second, microseconds=date.microsecond)
131
132
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000133def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000134 begin = (datetime_to_midnight(date) -
135 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000136 return begin, begin + relativedelta(months=3)
137
138
139def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000140 begin = (datetime_to_midnight(date) -
141 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000142 return begin, begin + relativedelta(years=1)
143
144
145def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000146 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000147 return begin, begin + timedelta(days=7)
148
149
150def get_yes_or_no(msg):
151 while True:
152 response = raw_input(msg + ' yes/no [no] ')
153 if response == 'y' or response == 'yes':
154 return True
155 elif not response or response == 'n' or response == 'no':
156 return False
157
158
deymo@chromium.org6c039202013-09-12 12:28:12 +0000159def datetime_from_gerrit(date_string):
160 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
161
162
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000163def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000164 try:
165 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
166 except ValueError:
167 # Sometimes rietveld returns a value without the milliseconds part, so we
168 # attempt to parse those cases as well.
169 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000170
171
172def datetime_from_google_code(date_string):
173 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
174
175
176class MyActivity(object):
177 def __init__(self, options):
178 self.options = options
179 self.modified_after = options.begin
180 self.modified_before = options.end
181 self.user = options.user
182 self.changes = []
183 self.reviews = []
184 self.issues = []
185 self.check_cookies()
186 self.google_code_auth_token = None
187
188 # Check the codereview cookie jar to determine which Rietveld instances to
189 # authenticate to.
190 def check_cookies(self):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000191 filtered_instances = []
192
193 def has_cookie(instance):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000194 auth_config = auth.extract_auth_config_from_options(self.options)
195 a = auth.get_authenticator_for_host(instance['url'], auth_config)
196 return a.has_cached_credentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000197
198 for instance in rietveld_instances:
199 instance['auth'] = has_cookie(instance)
200
201 if filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000202 logging.warning('No cookie found for the following Rietveld instance%s:',
203 's' if len(filtered_instances) > 1 else '')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000204 for instance in filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000205 logging.warning('\t' + instance['url'])
206 logging.warning('Use --auth if you would like to authenticate to them.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000207
208 def rietveld_search(self, instance, owner=None, reviewer=None):
209 if instance['requires_auth'] and not instance['auth']:
210 return []
211
212
213 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000214 auth_config = auth.extract_auth_config_from_options(self.options)
215 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000216
217 # See def search() in rietveld.py to see all the filters you can use.
218 query_modified_after = None
219
220 if instance['supports_owner_modified_query']:
221 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
222
223 # Rietveld does not allow search by both created_before and modified_after.
224 # (And some instances don't allow search by both owner and modified_after)
225 owner_email = None
226 reviewer_email = None
227 if owner:
228 owner_email = owner + '@' + instance['email_domain']
229 if reviewer:
230 reviewer_email = reviewer + '@' + instance['email_domain']
231 issues = remote.search(
232 owner=owner_email,
233 reviewer=reviewer_email,
234 modified_after=query_modified_after,
235 with_messages=True)
236
237 issues = filter(
238 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
239 issues)
240 issues = filter(
241 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
242 issues)
243
244 should_filter_by_user = True
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000245 issues = map(partial(self.process_rietveld_issue, remote, instance), issues)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000246 issues = filter(
247 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
248 issues)
249 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
250
251 return issues
252
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000253 def process_rietveld_issue(self, remote, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000254 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000255 if self.options.deltas:
256 patchset_props = remote.get_patchset_properties(
257 issue['issue'],
258 issue['patchsets'][-1])
259 ret['delta'] = '+%d,-%d' % (
260 sum(f['num_added'] for f in patchset_props['files'].itervalues()),
261 sum(f['num_removed'] for f in patchset_props['files'].itervalues()))
262
263 if issue['landed_days_ago'] != 'unknown':
264 ret['status'] = 'committed'
265 elif issue['closed']:
266 ret['status'] = 'closed'
267 elif len(issue['reviewers']) and issue['all_required_reviewers_approved']:
268 ret['status'] = 'ready'
269 else:
270 ret['status'] = 'open'
271
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000272 ret['owner'] = issue['owner_email']
273 ret['author'] = ret['owner']
274
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000275 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000276
277 shorturl = instance['url']
278 if 'shorturl' in instance:
279 shorturl = instance['shorturl']
280
281 ret['review_url'] = 'http://%s/%d' % (shorturl, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000282
283 # Rietveld sometimes has '\r\n' instead of '\n'.
284 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000285
286 ret['modified'] = datetime_from_rietveld(issue['modified'])
287 ret['created'] = datetime_from_rietveld(issue['created'])
288 ret['replies'] = self.process_rietveld_replies(issue['messages'])
289
290 return ret
291
292 @staticmethod
293 def process_rietveld_replies(replies):
294 ret = []
295 for reply in replies:
296 r = {}
297 r['author'] = reply['sender']
298 r['created'] = datetime_from_rietveld(reply['date'])
299 r['content'] = ''
300 ret.append(r)
301 return ret
302
deymo@chromium.org6c039202013-09-12 12:28:12 +0000303 @staticmethod
304 def gerrit_changes_over_ssh(instance, filters):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000305 # See https://review.openstack.org/Documentation/cmd-query.html
306 # Gerrit doesn't allow filtering by created time, only modified time.
deymo@chromium.org6c039202013-09-12 12:28:12 +0000307 gquery_cmd = ['ssh', '-p', str(instance['port']), instance['host'],
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000308 'gerrit', 'query',
309 '--format', 'JSON',
310 '--comments',
deymo@chromium.org6c039202013-09-12 12:28:12 +0000311 '--'] + filters
312 (stdout, _) = subprocess.Popen(gquery_cmd, stdout=subprocess.PIPE,
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000313 stderr=subprocess.PIPE).communicate()
deymo@chromium.org6c039202013-09-12 12:28:12 +0000314 # Drop the last line of the output with the stats.
315 issues = stdout.splitlines()[:-1]
316 return map(json.loads, issues)
317
318 @staticmethod
319 def gerrit_changes_over_rest(instance, filters):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000320 # Convert the "key:value" filter to a dictionary.
321 req = dict(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000322 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000323 # Instantiate the generator to force all the requests now and catch the
324 # errors here.
325 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
326 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS']))
327 except gerrit_util.GerritError, e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000328 logging.error('Looking up %r: %s', instance['url'], e)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000329 return []
330
deymo@chromium.org6c039202013-09-12 12:28:12 +0000331 def gerrit_search(self, instance, owner=None, reviewer=None):
332 max_age = datetime.today() - self.modified_after
333 max_age = max_age.days * 24 * 3600 + max_age.seconds
334 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
335 filters = ['-age:%ss' % max_age, user_filter]
336
337 # Determine the gerrit interface to use: SSH or REST API:
338 if 'host' in instance:
339 issues = self.gerrit_changes_over_ssh(instance, filters)
340 issues = [self.process_gerrit_ssh_issue(instance, issue)
341 for issue in issues]
342 elif 'url' in instance:
343 issues = self.gerrit_changes_over_rest(instance, filters)
344 issues = [self.process_gerrit_rest_issue(instance, issue)
345 for issue in issues]
346 else:
347 raise Exception('Invalid gerrit_instances configuration.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000348
349 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000350 issues = filter(self.filter_issue, issues)
351 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
352
353 return issues
354
deymo@chromium.org6c039202013-09-12 12:28:12 +0000355 def process_gerrit_ssh_issue(self, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000356 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000357 if self.options.deltas:
358 ret['delta'] = DefaultFormatter().format(
359 '+{insertions},-{deletions}',
360 **issue)
361 ret['status'] = issue['status']
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000362 ret['review_url'] = issue['url']
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +0000363 if 'shorturl' in instance:
364 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
365 issue['number'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000366 ret['header'] = issue['subject']
367 ret['owner'] = issue['owner']['email']
368 ret['author'] = ret['owner']
369 ret['created'] = datetime.fromtimestamp(issue['createdOn'])
370 ret['modified'] = datetime.fromtimestamp(issue['lastUpdated'])
371 if 'comments' in issue:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000372 ret['replies'] = self.process_gerrit_ssh_issue_replies(issue['comments'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000373 else:
374 ret['replies'] = []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000375 ret['reviewers'] = set(r['author'] for r in ret['replies'])
376 ret['reviewers'].discard(ret['author'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000377 return ret
378
379 @staticmethod
deymo@chromium.org6c039202013-09-12 12:28:12 +0000380 def process_gerrit_ssh_issue_replies(replies):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000381 ret = []
382 replies = filter(lambda r: 'email' in r['reviewer'], replies)
383 for reply in replies:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000384 ret.append({
385 'author': reply['reviewer']['email'],
386 'created': datetime.fromtimestamp(reply['timestamp']),
387 'content': '',
388 })
389 return ret
390
391 def process_gerrit_rest_issue(self, instance, issue):
392 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000393 if self.options.deltas:
394 ret['delta'] = DefaultFormatter().format(
395 '+{insertions},-{deletions}',
396 **issue)
397 ret['status'] = issue['status']
deymo@chromium.org6c039202013-09-12 12:28:12 +0000398 ret['review_url'] = 'https://%s/%s' % (instance['url'], issue['_number'])
399 if 'shorturl' in instance:
400 # TODO(deymo): Move this short link to https once crosreview.com supports
401 # it.
402 ret['review_url'] = 'http://%s/%s' % (instance['shorturl'],
403 issue['_number'])
404 ret['header'] = issue['subject']
405 ret['owner'] = issue['owner']['email']
406 ret['author'] = ret['owner']
407 ret['created'] = datetime_from_gerrit(issue['created'])
408 ret['modified'] = datetime_from_gerrit(issue['updated'])
409 if 'messages' in issue:
410 ret['replies'] = self.process_gerrit_rest_issue_replies(issue['messages'])
411 else:
412 ret['replies'] = []
413 ret['reviewers'] = set(r['author'] for r in ret['replies'])
414 ret['reviewers'].discard(ret['author'])
415 return ret
416
417 @staticmethod
418 def process_gerrit_rest_issue_replies(replies):
419 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000420 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
421 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000422 for reply in replies:
423 ret.append({
424 'author': reply['author']['email'],
425 'created': datetime_from_gerrit(reply['date']),
426 'content': reply['message'],
427 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000428 return ret
429
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000430 def project_hosting_issue_search(self, instance):
431 auth_config = auth.extract_auth_config_from_options(self.options)
432 authenticator = auth.get_authenticator_for_host(
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000433 'bugs.chromium.org', auth_config)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000434 http = authenticator.authorize(httplib2.Http())
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000435 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
436 '/%s/issues') % instance['name']
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000437 epoch = datetime.utcfromtimestamp(0)
438 user_str = '%s@chromium.org' % self.user
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000439
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000440 query_data = urllib.urlencode({
441 'maxResults': 10000,
442 'q': user_str,
443 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
444 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000445 })
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000446 url = url + '?' + query_data
447 _, body = http.request(url)
448 content = json.loads(body)
449 if not content:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000450 logging.error('Unable to parse %s response from projecthosting.',
451 instance['name'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000452 return []
453
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000454 issues = []
455 if 'items' in content:
456 items = content['items']
457 for item in items:
458 issue = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000459 'header': item['title'],
460 'created': dateutil.parser.parse(item['published']),
461 'modified': dateutil.parser.parse(item['updated']),
462 'author': item['author']['name'],
463 'url': 'https://code.google.com/p/%s/issues/detail?id=%s' % (
464 instance['name'], item['id']),
465 'comments': [],
466 'status': item['status'],
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000467 }
jdoerrie64287842017-01-09 14:40:26 +0100468 if 'shorturl' in instance:
469 issue['url'] = 'http://%s/%d' % (instance['shorturl'], item['id'])
470
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000471 if 'owner' in item:
472 issue['owner'] = item['owner']['name']
473 else:
474 issue['owner'] = 'None'
475 if issue['owner'] == user_str or issue['author'] == user_str:
476 issues.append(issue)
477
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000478 return issues
479
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000480 def print_heading(self, heading):
481 print
482 print self.options.output_format_heading.format(heading=heading)
483
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000484 def match(self, author):
485 if '@' in self.user:
486 return author == self.user
487 return author.startswith(self.user + '@')
488
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000489 def print_change(self, change):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000490 activity = len([
491 reply
492 for reply in change['replies']
493 if self.match(reply['author'])
494 ])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000495 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000496 'created': change['created'].date().isoformat(),
497 'modified': change['modified'].date().isoformat(),
498 'reviewers': ', '.join(change['reviewers']),
499 'status': change['status'],
500 'activity': activity,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000501 }
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000502 if self.options.deltas:
503 optional_values['delta'] = change['delta']
504
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000505 self.print_generic(self.options.output_format,
506 self.options.output_format_changes,
507 change['header'],
508 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000509 change['author'],
510 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000511
512 def print_issue(self, issue):
513 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000514 'created': issue['created'].date().isoformat(),
515 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000516 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000517 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000518 }
519 self.print_generic(self.options.output_format,
520 self.options.output_format_issues,
521 issue['header'],
522 issue['url'],
523 issue['author'],
524 optional_values)
525
526 def print_review(self, review):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000527 activity = len([
528 reply
529 for reply in review['replies']
530 if self.match(reply['author'])
531 ])
532 optional_values = {
533 'created': review['created'].date().isoformat(),
534 'modified': review['modified'].date().isoformat(),
535 'activity': activity,
536 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000537 self.print_generic(self.options.output_format,
538 self.options.output_format_reviews,
539 review['header'],
540 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000541 review['author'],
542 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000543
544 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000545 def print_generic(default_fmt, specific_fmt,
546 title, url, author,
547 optional_values=None):
548 output_format = specific_fmt if specific_fmt is not None else default_fmt
549 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000550 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000551 'title': title,
552 'url': url,
553 'author': author,
554 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000555 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000556 values.update(optional_values)
557 print DefaultFormatter().format(output_format, **values).encode(
558 sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000559
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000560
561 def filter_issue(self, issue, should_filter_by_user=True):
562 def maybe_filter_username(email):
563 return not should_filter_by_user or username(email) == self.user
564 if (maybe_filter_username(issue['author']) and
565 self.filter_modified(issue['created'])):
566 return True
567 if (maybe_filter_username(issue['owner']) and
568 (self.filter_modified(issue['created']) or
569 self.filter_modified(issue['modified']))):
570 return True
571 for reply in issue['replies']:
572 if self.filter_modified(reply['created']):
573 if not should_filter_by_user:
574 break
575 if (username(reply['author']) == self.user
576 or (self.user + '@') in reply['content']):
577 break
578 else:
579 return False
580 return True
581
582 def filter_modified(self, modified):
583 return self.modified_after < modified and modified < self.modified_before
584
585 def auth_for_changes(self):
586 #TODO(cjhopman): Move authentication check for getting changes here.
587 pass
588
589 def auth_for_reviews(self):
590 # Reviews use all the same instances as changes so no authentication is
591 # required.
592 pass
593
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000594 def get_changes(self):
595 for instance in rietveld_instances:
596 self.changes += self.rietveld_search(instance, owner=self.user)
597
598 for instance in gerrit_instances:
599 self.changes += self.gerrit_search(instance, owner=self.user)
600
601 def print_changes(self):
602 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000603 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000604 for change in self.changes:
605 self.print_change(change)
606
607 def get_reviews(self):
608 for instance in rietveld_instances:
609 self.reviews += self.rietveld_search(instance, reviewer=self.user)
610
611 for instance in gerrit_instances:
612 reviews = self.gerrit_search(instance, reviewer=self.user)
613 reviews = filter(lambda r: not username(r['owner']) == self.user, reviews)
614 self.reviews += reviews
615
616 def print_reviews(self):
617 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000618 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000619 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000620 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000621
622 def get_issues(self):
623 for project in google_code_projects:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000624 self.issues += self.project_hosting_issue_search(project)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000625
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000626 def print_issues(self):
627 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000628 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000629 for issue in self.issues:
630 self.print_issue(issue)
631
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000632 def print_activity(self):
633 self.print_changes()
634 self.print_reviews()
635 self.print_issues()
636
637
638def main():
639 # Silence upload.py.
640 rietveld.upload.verbosity = 0
641
642 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
643 parser.add_option(
644 '-u', '--user', metavar='<email>',
645 default=os.environ.get('USER'),
646 help='Filter on user, default=%default')
647 parser.add_option(
648 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000649 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000650 parser.add_option(
651 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000652 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000653 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
654 relativedelta(months=2))
655 parser.add_option(
656 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000657 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000658 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
659 parser.add_option(
660 '-Y', '--this_year', action='store_true',
661 help='Use this year\'s dates')
662 parser.add_option(
663 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000664 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000665 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000666 '-W', '--last_week', action='count',
667 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000668 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000669 '-a', '--auth',
670 action='store_true',
671 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000672 parser.add_option(
673 '-d', '--deltas',
674 action='store_true',
675 help='Fetch deltas for changes (slow).')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000676
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000677 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000678 'By default, all activity will be looked up and '
679 'printed. If any of these are specified, only '
680 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000681 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000682 '-c', '--changes',
683 action='store_true',
684 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000685 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000686 '-i', '--issues',
687 action='store_true',
688 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000689 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000690 '-r', '--reviews',
691 action='store_true',
692 help='Show reviews.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000693 parser.add_option_group(activity_types_group)
694
695 output_format_group = optparse.OptionGroup(parser, 'Output Format',
696 'By default, all activity will be printed in the '
697 'following format: {url} {title}. This can be '
698 'changed for either all activity types or '
699 'individually for each activity type. The format '
700 'is defined as documented for '
701 'string.format(...). The variables available for '
702 'all activity types are url, title and author. '
703 'Format options for specific activity types will '
704 'override the generic format.')
705 output_format_group.add_option(
706 '-f', '--output-format', metavar='<format>',
707 default=u'{url} {title}',
708 help='Specifies the format to use when printing all your activity.')
709 output_format_group.add_option(
710 '--output-format-changes', metavar='<format>',
711 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000712 help='Specifies the format to use when printing changes. Supports the '
713 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000714 output_format_group.add_option(
715 '--output-format-issues', metavar='<format>',
716 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000717 help='Specifies the format to use when printing issues. Supports the '
718 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000719 output_format_group.add_option(
720 '--output-format-reviews', metavar='<format>',
721 default=None,
722 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000723 output_format_group.add_option(
724 '--output-format-heading', metavar='<format>',
725 default=u'{heading}:',
726 help='Specifies the format to use when printing headings.')
727 output_format_group.add_option(
728 '-m', '--markdown', action='store_true',
729 help='Use markdown-friendly output (overrides --output-format '
730 'and --output-format-heading)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000731 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000732 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000733
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000734 parser.add_option(
735 '-v', '--verbose',
736 action='store_const',
737 dest='verbosity',
738 default=logging.WARN,
739 const=logging.INFO,
740 help='Output extra informational messages.'
741 )
742 parser.add_option(
743 '-q', '--quiet',
744 action='store_const',
745 dest='verbosity',
746 const=logging.ERROR,
747 help='Suppress non-error messages.'
748 )
749
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000750 # Remove description formatting
751 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -0800752 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000753
754 options, args = parser.parse_args()
755 options.local_user = os.environ.get('USER')
756 if args:
757 parser.error('Args unsupported')
758 if not options.user:
759 parser.error('USER is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000760 options.user = username(options.user)
761
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000762 logging.basicConfig(level=options.verbosity)
763
764 # python-keyring provides easy access to the system keyring.
765 try:
766 import keyring # pylint: disable=unused-import,unused-variable,F0401
767 except ImportError:
768 logging.warning('Consider installing python-keyring')
769
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000770 if not options.begin:
771 if options.last_quarter:
772 begin, end = quarter_begin, quarter_end
773 elif options.this_year:
774 begin, end = get_year_of(datetime.today())
775 elif options.week_of:
776 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000777 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000778 begin, end = (get_week_of(datetime.today() -
779 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000780 else:
781 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
782 else:
783 begin = datetime.strptime(options.begin, '%m/%d/%y')
784 if options.end:
785 end = datetime.strptime(options.end, '%m/%d/%y')
786 else:
787 end = datetime.today()
788 options.begin, options.end = begin, end
789
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000790 if options.markdown:
791 options.output_format = ' * [{title}]({url})'
792 options.output_format_heading = '### {heading} ###'
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000793 logging.info('Searching for activity by %s', options.user)
794 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000795
796 my_activity = MyActivity(options)
797
798 if not (options.changes or options.reviews or options.issues):
799 options.changes = True
800 options.issues = True
801 options.reviews = True
802
803 # First do any required authentication so none of the user interaction has to
804 # wait for actual work.
805 if options.changes:
806 my_activity.auth_for_changes()
807 if options.reviews:
808 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000809
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000810 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000811
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000812 try:
813 if options.changes:
814 my_activity.get_changes()
815 if options.reviews:
816 my_activity.get_reviews()
817 if options.issues:
818 my_activity.get_issues()
819 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000820 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000821
822 my_activity.print_changes()
823 my_activity.print_reviews()
824 my_activity.print_issues()
825 return 0
826
827
828if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +0000829 # Fix encoding to support non-ascii issue titles.
830 fix_encoding.fix_encoding()
831
sbc@chromium.org013731e2015-02-26 18:28:43 +0000832 try:
833 sys.exit(main())
834 except KeyboardInterrupt:
835 sys.stderr.write('interrupted\n')
836 sys.exit(1)