blob: 529754b8ff55a0c812ca4ea07485486f0bf4cfdc [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.
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000014 - my_activity.py -jd to output stats for the week to json with deltas data.
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000015"""
16
17# These services typically only provide a created time and a last modified time
18# for each item for general queries. This is not enough to determine if there
19# was activity in a given time period. So, we first query for all things created
20# before end and modified after begin. Then, we get the details of each item and
21# check those details to determine if there was activity in the given period.
22# This means that query time scales mostly with (today() - begin).
23
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +010024import collections
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +010025import contextlib
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000026from datetime import datetime
27from datetime import timedelta
28from functools import partial
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +010029import itertools
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000030import json
Tobias Sargeantffb3c432017-03-08 14:09:14 +000031import logging
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +010032from multiprocessing.pool import ThreadPool
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000033import optparse
34import os
35import subprocess
Tobias Sargeantffb3c432017-03-08 14:09:14 +000036from string import Formatter
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000037import sys
38import urllib
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000039import re
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000040
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000041import auth
stevefung@chromium.org832d51e2015-05-27 12:52:51 +000042import fix_encoding
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000043import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000044import rietveld
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000045
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000046from third_party import httplib2
47
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000048try:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000049 import dateutil # pylint: disable=import-error
50 import dateutil.parser
51 from dateutil.relativedelta import relativedelta
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000052except ImportError:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000053 logging.error('python-dateutil package required')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000054 exit(1)
55
Tobias Sargeantffb3c432017-03-08 14:09:14 +000056
57class DefaultFormatter(Formatter):
58 def __init__(self, default = ''):
59 super(DefaultFormatter, self).__init__()
60 self.default = default
61
62 def get_value(self, key, args, kwds):
63 if isinstance(key, basestring) and key not in kwds:
64 return self.default
65 return Formatter.get_value(self, key, args, kwds)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000066
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000067rietveld_instances = [
68 {
69 'url': 'codereview.chromium.org',
70 'shorturl': 'crrev.com',
71 'supports_owner_modified_query': True,
72 'requires_auth': False,
73 'email_domain': 'chromium.org',
Varun Khanejad9f97bc2017-08-02 12:55:01 -070074 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000075 },
76 {
77 'url': 'chromereviews.googleplex.com',
78 'shorturl': 'go/chromerev',
79 'supports_owner_modified_query': True,
80 'requires_auth': True,
81 'email_domain': 'google.com',
82 },
83 {
84 'url': 'codereview.appspot.com',
85 'supports_owner_modified_query': True,
86 'requires_auth': False,
87 'email_domain': 'chromium.org',
88 },
89 {
90 'url': 'breakpad.appspot.com',
91 'supports_owner_modified_query': False,
92 'requires_auth': False,
93 'email_domain': 'chromium.org',
94 },
95]
96
97gerrit_instances = [
98 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000099 'url': 'chromium-review.googlesource.com',
Jeremy Romanf475a472017-06-06 16:49:11 -0400100 'shorturl': 'crrev.com/c',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700101 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000102 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000103 {
104 'url': 'chrome-internal-review.googlesource.com',
Jeremy Romanf475a472017-06-06 16:49:11 -0400105 'shorturl': 'crrev.com/i',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700106 'short_url_protocol': 'https',
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000107 },
deymo@chromium.org56dc57a2015-09-10 18:26:54 +0000108 {
109 'url': 'android-review.googlesource.com',
110 },
Ryan Harrison897602a2017-09-18 16:23:41 -0400111 {
Raul E Rangelc27134c2018-08-20 19:15:09 +0000112 'url': 'review.coreboot.org',
113 },
114 {
Ryan Harrison897602a2017-09-18 16:23:41 -0400115 'url': 'pdfium-review.googlesource.com',
116 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000117]
118
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100119monorail_projects = {
120 'chromium': {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000121 'shorturl': 'crbug.com',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700122 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000123 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100124 'google-breakpad': {},
125 'gyp': {},
126 'skia': {},
127 'pdfium': {
Ryan Harrison897602a2017-09-18 16:23:41 -0400128 'shorturl': 'crbug.com/pdfium',
129 'short_url_protocol': 'https',
130 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100131 'v8': {
132 'shorturl': 'crbug.com/v8',
133 'short_url_protocol': 'https',
134 },
135}
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000136
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000137def username(email):
138 """Keeps the username of an email address."""
139 return email and email.split('@', 1)[0]
140
141
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000142def datetime_to_midnight(date):
143 return date - timedelta(hours=date.hour, minutes=date.minute,
144 seconds=date.second, microseconds=date.microsecond)
145
146
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000147def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000148 begin = (datetime_to_midnight(date) -
149 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000150 return begin, begin + relativedelta(months=3)
151
152
153def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000154 begin = (datetime_to_midnight(date) -
155 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000156 return begin, begin + relativedelta(years=1)
157
158
159def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000160 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000161 return begin, begin + timedelta(days=7)
162
163
164def get_yes_or_no(msg):
165 while True:
166 response = raw_input(msg + ' yes/no [no] ')
167 if response == 'y' or response == 'yes':
168 return True
169 elif not response or response == 'n' or response == 'no':
170 return False
171
172
deymo@chromium.org6c039202013-09-12 12:28:12 +0000173def datetime_from_gerrit(date_string):
174 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
175
176
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000177def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000178 try:
179 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
180 except ValueError:
181 # Sometimes rietveld returns a value without the milliseconds part, so we
182 # attempt to parse those cases as well.
183 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000184
185
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100186def datetime_from_monorail(date_string):
187 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000188
189
190class MyActivity(object):
191 def __init__(self, options):
192 self.options = options
193 self.modified_after = options.begin
194 self.modified_before = options.end
195 self.user = options.user
196 self.changes = []
197 self.reviews = []
198 self.issues = []
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100199 self.referenced_issues = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000200 self.check_cookies()
201 self.google_code_auth_token = None
Vadim Bendebury8de38002018-05-14 19:02:55 -0700202 self.access_errors = set()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000203
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100204 def show_progress(self, how='.'):
205 if sys.stdout.isatty():
206 sys.stdout.write(how)
207 sys.stdout.flush()
208
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000209 # Check the codereview cookie jar to determine which Rietveld instances to
210 # authenticate to.
211 def check_cookies(self):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000212 filtered_instances = []
213
214 def has_cookie(instance):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000215 auth_config = auth.extract_auth_config_from_options(self.options)
216 a = auth.get_authenticator_for_host(instance['url'], auth_config)
217 return a.has_cached_credentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000218
219 for instance in rietveld_instances:
220 instance['auth'] = has_cookie(instance)
221
222 if filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000223 logging.warning('No cookie found for the following Rietveld instance%s:',
224 's' if len(filtered_instances) > 1 else '')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000225 for instance in filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000226 logging.warning('\t' + instance['url'])
227 logging.warning('Use --auth if you would like to authenticate to them.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000228
229 def rietveld_search(self, instance, owner=None, reviewer=None):
230 if instance['requires_auth'] and not instance['auth']:
231 return []
232
233
234 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000235 auth_config = auth.extract_auth_config_from_options(self.options)
236 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000237
238 # See def search() in rietveld.py to see all the filters you can use.
239 query_modified_after = None
240
241 if instance['supports_owner_modified_query']:
242 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
243
244 # Rietveld does not allow search by both created_before and modified_after.
245 # (And some instances don't allow search by both owner and modified_after)
246 owner_email = None
247 reviewer_email = None
248 if owner:
249 owner_email = owner + '@' + instance['email_domain']
250 if reviewer:
251 reviewer_email = reviewer + '@' + instance['email_domain']
252 issues = remote.search(
253 owner=owner_email,
254 reviewer=reviewer_email,
255 modified_after=query_modified_after,
256 with_messages=True)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100257 self.show_progress()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000258
259 issues = filter(
260 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
261 issues)
262 issues = filter(
263 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
264 issues)
265
266 should_filter_by_user = True
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000267 issues = map(partial(self.process_rietveld_issue, remote, instance), issues)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000268 issues = filter(
269 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
270 issues)
271 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
272
273 return issues
274
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000275 def extract_bug_numbers_from_description(self, issue):
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000276 description = None
277
278 if 'description' in issue:
279 # Getting the description for Rietveld
280 description = issue['description']
281 elif 'revisions' in issue:
282 # Getting the description for REST Gerrit
283 revision = issue['revisions'][issue['current_revision']]
284 description = revision['commit']['message']
285
286 bugs = []
287 if description:
Nicolas Dossou-gbete903ea732017-07-10 16:46:59 +0100288 # Handle both "Bug: 99999" and "BUG=99999" bug notations
289 # Multiple bugs can be noted on a single line or in multiple ones.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100290 matches = re.findall(
291 r'BUG[=:]\s?((((?:[a-zA-Z0-9-]+:)?\d+)(,\s?)?)+)', description,
292 flags=re.IGNORECASE)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000293 if matches:
294 for match in matches:
295 bugs.extend(match[0].replace(' ', '').split(','))
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100296 # Add default chromium: prefix if none specified.
297 bugs = [bug if ':' in bug else 'chromium:%s' % bug for bug in bugs]
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000298
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000299 return sorted(set(bugs))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000300
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000301 def process_rietveld_issue(self, remote, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000302 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000303 if self.options.deltas:
304 patchset_props = remote.get_patchset_properties(
305 issue['issue'],
306 issue['patchsets'][-1])
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100307 self.show_progress()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000308 ret['delta'] = '+%d,-%d' % (
309 sum(f['num_added'] for f in patchset_props['files'].itervalues()),
310 sum(f['num_removed'] for f in patchset_props['files'].itervalues()))
311
312 if issue['landed_days_ago'] != 'unknown':
313 ret['status'] = 'committed'
314 elif issue['closed']:
315 ret['status'] = 'closed'
316 elif len(issue['reviewers']) and issue['all_required_reviewers_approved']:
317 ret['status'] = 'ready'
318 else:
319 ret['status'] = 'open'
320
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000321 ret['owner'] = issue['owner_email']
322 ret['author'] = ret['owner']
323
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000324 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000325
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000326 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700327 url = instance['shorturl']
328 protocol = instance.get('short_url_protocol', 'http')
329 else:
330 url = instance['url']
331 protocol = 'https'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000332
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700333 ret['review_url'] = '%s://%s/%d' % (protocol, url, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000334
335 # Rietveld sometimes has '\r\n' instead of '\n'.
336 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000337
338 ret['modified'] = datetime_from_rietveld(issue['modified'])
339 ret['created'] = datetime_from_rietveld(issue['created'])
340 ret['replies'] = self.process_rietveld_replies(issue['messages'])
341
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000342 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000343 ret['landed_days_ago'] = issue['landed_days_ago']
344
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000345 return ret
346
347 @staticmethod
348 def process_rietveld_replies(replies):
349 ret = []
350 for reply in replies:
351 r = {}
352 r['author'] = reply['sender']
353 r['created'] = datetime_from_rietveld(reply['date'])
354 r['content'] = ''
355 ret.append(r)
356 return ret
357
Vadim Bendebury8de38002018-05-14 19:02:55 -0700358 def gerrit_changes_over_rest(self, instance, filters):
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200359 # Convert the "key:value" filter to a list of (key, value) pairs.
360 req = list(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000361 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000362 # Instantiate the generator to force all the requests now and catch the
363 # errors here.
364 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000365 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS',
366 'CURRENT_REVISION', 'CURRENT_COMMIT']))
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000367 except gerrit_util.GerritError, e:
Vadim Bendebury8de38002018-05-14 19:02:55 -0700368 error_message = 'Looking up %r: %s' % (instance['url'], e)
369 if error_message not in self.access_errors:
370 self.access_errors.add(error_message)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000371 return []
372
deymo@chromium.org6c039202013-09-12 12:28:12 +0000373 def gerrit_search(self, instance, owner=None, reviewer=None):
374 max_age = datetime.today() - self.modified_after
375 max_age = max_age.days * 24 * 3600 + max_age.seconds
376 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
377 filters = ['-age:%ss' % max_age, user_filter]
378
Aaron Gable2979a872017-09-05 17:38:32 -0700379 issues = self.gerrit_changes_over_rest(instance, filters)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100380 self.show_progress()
Aaron Gable2979a872017-09-05 17:38:32 -0700381 issues = [self.process_gerrit_issue(instance, issue)
382 for issue in issues]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000383
384 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000385 issues = filter(self.filter_issue, issues)
386 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
387
388 return issues
389
Aaron Gable2979a872017-09-05 17:38:32 -0700390 def process_gerrit_issue(self, instance, issue):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000391 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000392 if self.options.deltas:
393 ret['delta'] = DefaultFormatter().format(
394 '+{insertions},-{deletions}',
395 **issue)
396 ret['status'] = issue['status']
deymo@chromium.org6c039202013-09-12 12:28:12 +0000397 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700398 protocol = instance.get('short_url_protocol', 'http')
399 url = instance['shorturl']
400 else:
401 protocol = 'https'
402 url = instance['url']
403 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
404
deymo@chromium.org6c039202013-09-12 12:28:12 +0000405 ret['header'] = issue['subject']
Don Garrett2ebf9fd2018-08-06 15:14:00 +0000406 ret['owner'] = issue['owner'].get('email', '')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000407 ret['author'] = ret['owner']
408 ret['created'] = datetime_from_gerrit(issue['created'])
409 ret['modified'] = datetime_from_gerrit(issue['updated'])
410 if 'messages' in issue:
Aaron Gable2979a872017-09-05 17:38:32 -0700411 ret['replies'] = self.process_gerrit_issue_replies(issue['messages'])
deymo@chromium.org6c039202013-09-12 12:28:12 +0000412 else:
413 ret['replies'] = []
414 ret['reviewers'] = set(r['author'] for r in ret['replies'])
415 ret['reviewers'].discard(ret['author'])
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000416 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000417 return ret
418
419 @staticmethod
Aaron Gable2979a872017-09-05 17:38:32 -0700420 def process_gerrit_issue_replies(replies):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000421 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000422 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
423 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000424 for reply in replies:
425 ret.append({
426 'author': reply['author']['email'],
427 'created': datetime_from_gerrit(reply['date']),
428 'content': reply['message'],
429 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000430 return ret
431
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100432 def monorail_get_auth_http(self):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000433 auth_config = auth.extract_auth_config_from_options(self.options)
434 authenticator = auth.get_authenticator_for_host(
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000435 'bugs.chromium.org', auth_config)
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100436 return authenticator.authorize(httplib2.Http())
437
438 def filter_modified_monorail_issue(self, issue):
439 """Precisely checks if an issue has been modified in the time range.
440
441 This fetches all issue comments to check if the issue has been modified in
442 the time range specified by user. This is needed because monorail only
443 allows filtering by last updated and published dates, which is not
444 sufficient to tell whether a given issue has been modified at some specific
445 time range. Any update to the issue is a reported as comment on Monorail.
446
447 Args:
448 issue: Issue dict as returned by monorail_query_issues method. In
449 particular, must have a key 'uid' formatted as 'project:issue_id'.
450
451 Returns:
452 Passed issue if modified, None otherwise.
453 """
454 http = self.monorail_get_auth_http()
455 project, issue_id = issue['uid'].split(':')
456 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
457 '/%s/issues/%s/comments?maxResults=10000') % (project, issue_id)
458 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100459 self.show_progress()
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100460 content = json.loads(body)
461 if not content:
462 logging.error('Unable to parse %s response from monorail.', project)
463 return issue
464
465 for item in content.get('items', []):
466 comment_published = datetime_from_monorail(item['published'])
467 if self.filter_modified(comment_published):
468 return issue
469
470 return None
471
472 def monorail_query_issues(self, project, query):
473 http = self.monorail_get_auth_http()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000474 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100475 '/%s/issues') % project
476 query_data = urllib.urlencode(query)
477 url = url + '?' + query_data
478 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100479 self.show_progress()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100480 content = json.loads(body)
481 if not content:
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100482 logging.error('Unable to parse %s response from monorail.', project)
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100483 return []
484
485 issues = []
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100486 project_config = monorail_projects.get(project, {})
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100487 for item in content.get('items', []):
488 if project_config.get('shorturl'):
489 protocol = project_config.get('short_url_protocol', 'http')
490 item_url = '%s://%s/%d' % (
491 protocol, project_config['shorturl'], item['id'])
492 else:
493 item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
494 project, item['id'])
495 issue = {
496 'uid': '%s:%s' % (project, item['id']),
497 'header': item['title'],
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100498 'created': datetime_from_monorail(item['published']),
499 'modified': datetime_from_monorail(item['updated']),
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100500 'author': item['author']['name'],
501 'url': item_url,
502 'comments': [],
503 'status': item['status'],
504 'labels': [],
505 'components': []
506 }
507 if 'owner' in item:
508 issue['owner'] = item['owner']['name']
509 else:
510 issue['owner'] = 'None'
511 if 'labels' in item:
512 issue['labels'] = item['labels']
513 if 'components' in item:
514 issue['components'] = item['components']
515 issues.append(issue)
516
517 return issues
518
519 def monorail_issue_search(self, project):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000520 epoch = datetime.utcfromtimestamp(0)
521 user_str = '%s@chromium.org' % self.user
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000522
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100523 issues = self.monorail_query_issues(project, {
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000524 'maxResults': 10000,
525 'q': user_str,
526 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
527 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000528 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000529
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100530 return [
531 issue for issue in issues
532 if issue['author'] == user_str or issue['owner'] == user_str]
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000533
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100534 def monorail_get_issues(self, project, issue_ids):
535 return self.monorail_query_issues(project, {
536 'maxResults': 10000,
537 'q': 'id:%s' % ','.join(issue_ids)
538 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000539
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000540 def print_heading(self, heading):
541 print
542 print self.options.output_format_heading.format(heading=heading)
543
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000544 def match(self, author):
545 if '@' in self.user:
546 return author == self.user
547 return author.startswith(self.user + '@')
548
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000549 def print_change(self, change):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000550 activity = len([
551 reply
552 for reply in change['replies']
553 if self.match(reply['author'])
554 ])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000555 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000556 'created': change['created'].date().isoformat(),
557 'modified': change['modified'].date().isoformat(),
558 'reviewers': ', '.join(change['reviewers']),
559 'status': change['status'],
560 'activity': activity,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000561 }
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000562 if self.options.deltas:
563 optional_values['delta'] = change['delta']
564
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000565 self.print_generic(self.options.output_format,
566 self.options.output_format_changes,
567 change['header'],
568 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000569 change['author'],
570 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000571
572 def print_issue(self, issue):
573 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000574 'created': issue['created'].date().isoformat(),
575 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000576 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000577 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000578 }
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):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000587 activity = len([
588 reply
589 for reply in review['replies']
590 if self.match(reply['author'])
591 ])
592 optional_values = {
593 'created': review['created'].date().isoformat(),
594 'modified': review['modified'].date().isoformat(),
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800595 'status': review['status'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000596 'activity': activity,
597 }
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800598 if self.options.deltas:
599 optional_values['delta'] = review['delta']
600
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000601 self.print_generic(self.options.output_format,
602 self.options.output_format_reviews,
603 review['header'],
604 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000605 review['author'],
606 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000607
608 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000609 def print_generic(default_fmt, specific_fmt,
610 title, url, author,
611 optional_values=None):
612 output_format = specific_fmt if specific_fmt is not None else default_fmt
613 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000614 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000615 'title': title,
616 'url': url,
617 'author': author,
618 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000619 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000620 values.update(optional_values)
621 print DefaultFormatter().format(output_format, **values).encode(
622 sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000623
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000624
625 def filter_issue(self, issue, should_filter_by_user=True):
626 def maybe_filter_username(email):
627 return not should_filter_by_user or username(email) == self.user
628 if (maybe_filter_username(issue['author']) and
629 self.filter_modified(issue['created'])):
630 return True
631 if (maybe_filter_username(issue['owner']) and
632 (self.filter_modified(issue['created']) or
633 self.filter_modified(issue['modified']))):
634 return True
635 for reply in issue['replies']:
636 if self.filter_modified(reply['created']):
637 if not should_filter_by_user:
638 break
639 if (username(reply['author']) == self.user
640 or (self.user + '@') in reply['content']):
641 break
642 else:
643 return False
644 return True
645
646 def filter_modified(self, modified):
647 return self.modified_after < modified and modified < self.modified_before
648
649 def auth_for_changes(self):
650 #TODO(cjhopman): Move authentication check for getting changes here.
651 pass
652
653 def auth_for_reviews(self):
654 # Reviews use all the same instances as changes so no authentication is
655 # required.
656 pass
657
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000658 def get_changes(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100659 num_instances = len(rietveld_instances) + len(gerrit_instances)
660 with contextlib.closing(ThreadPool(num_instances)) as pool:
661 rietveld_changes = pool.map_async(
662 lambda instance: self.rietveld_search(instance, owner=self.user),
663 rietveld_instances)
664 gerrit_changes = pool.map_async(
665 lambda instance: self.gerrit_search(instance, owner=self.user),
666 gerrit_instances)
667 rietveld_changes = itertools.chain.from_iterable(rietveld_changes.get())
668 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
669 self.changes = list(rietveld_changes) + list(gerrit_changes)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000670
671 def print_changes(self):
672 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000673 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000674 for change in self.changes:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100675 self.print_change(change)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000676
Vadim Bendebury8de38002018-05-14 19:02:55 -0700677 def print_access_errors(self):
678 if self.access_errors:
Ryan Harrison398fb442018-05-22 12:05:26 -0400679 logging.error('Access Errors:')
680 for error in self.access_errors:
681 logging.error(error.rstrip())
Vadim Bendebury8de38002018-05-14 19:02:55 -0700682
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000683 def get_reviews(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100684 num_instances = len(rietveld_instances) + len(gerrit_instances)
685 with contextlib.closing(ThreadPool(num_instances)) as pool:
686 rietveld_reviews = pool.map_async(
687 lambda instance: self.rietveld_search(instance, reviewer=self.user),
688 rietveld_instances)
689 gerrit_reviews = pool.map_async(
690 lambda instance: self.gerrit_search(instance, reviewer=self.user),
691 gerrit_instances)
692 rietveld_reviews = itertools.chain.from_iterable(rietveld_reviews.get())
693 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
jdoerrie356c2882018-07-23 10:02:02 +0000694 gerrit_reviews = [r for r in gerrit_reviews if not self.match(r['owner'])]
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100695 self.reviews = list(rietveld_reviews) + list(gerrit_reviews)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000696
697 def print_reviews(self):
698 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000699 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000700 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000701 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000702
703 def get_issues(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100704 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
705 monorail_issues = pool.map(
706 self.monorail_issue_search, monorail_projects.keys())
707 monorail_issues = list(itertools.chain.from_iterable(monorail_issues))
708
Vadim Bendeburycbf02042018-05-14 17:46:15 -0700709 if not monorail_issues:
710 return
711
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100712 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
713 filtered_issues = pool.map(
714 self.filter_modified_monorail_issue, monorail_issues)
715 self.issues = [issue for issue in filtered_issues if issue]
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100716
717 def get_referenced_issues(self):
718 if not self.issues:
719 self.get_issues()
720
721 if not self.changes:
722 self.get_changes()
723
724 referenced_issue_uids = set(itertools.chain.from_iterable(
725 change['bugs'] for change in self.changes))
726 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
727 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
728
729 missing_issues_by_project = collections.defaultdict(list)
730 for issue_uid in missing_issue_uids:
731 project, issue_id = issue_uid.split(':')
732 missing_issues_by_project[project].append(issue_id)
733
734 for project, issue_ids in missing_issues_by_project.iteritems():
735 self.referenced_issues += self.monorail_get_issues(project, issue_ids)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000736
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000737 def print_issues(self):
738 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000739 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000740 for issue in self.issues:
741 self.print_issue(issue)
742
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100743 def print_changes_by_issue(self, skip_empty_own):
744 if not self.issues or not self.changes:
745 return
746
747 self.print_heading('Changes by referenced issue(s)')
748 issues = {issue['uid']: issue for issue in self.issues}
749 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
750 changes_by_issue_uid = collections.defaultdict(list)
751 changes_by_ref_issue_uid = collections.defaultdict(list)
752 changes_without_issue = []
753 for change in self.changes:
754 added = False
Andrii Shyshkalov483580b2018-07-02 06:55:10 +0000755 for issue_uid in change['bugs']:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100756 if issue_uid in issues:
757 changes_by_issue_uid[issue_uid].append(change)
758 added = True
759 if issue_uid in ref_issues:
760 changes_by_ref_issue_uid[issue_uid].append(change)
761 added = True
762 if not added:
763 changes_without_issue.append(change)
764
765 # Changes referencing own issues.
766 for issue_uid in issues:
767 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
768 self.print_issue(issues[issue_uid])
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +0000769 if changes_by_issue_uid[issue_uid]:
770 print
771 for change in changes_by_issue_uid[issue_uid]:
772 print ' ', # this prints one space due to comma, but no newline
773 self.print_change(change)
774 print
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100775
776 # Changes referencing others' issues.
777 for issue_uid in ref_issues:
778 assert changes_by_ref_issue_uid[issue_uid]
779 self.print_issue(ref_issues[issue_uid])
780 for change in changes_by_ref_issue_uid[issue_uid]:
781 print '', # this prints one space due to comma, but no newline
782 self.print_change(change)
783
784 # Changes referencing no issues.
785 if changes_without_issue:
786 print self.options.output_format_no_url.format(title='Other changes')
787 for change in changes_without_issue:
788 print '', # this prints one space due to comma, but no newline
789 self.print_change(change)
790
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000791 def print_activity(self):
792 self.print_changes()
793 self.print_reviews()
794 self.print_issues()
795
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000796 def dump_json(self, ignore_keys=None):
797 if ignore_keys is None:
798 ignore_keys = ['replies']
799
800 def format_for_json_dump(in_array):
801 output = {}
802 for item in in_array:
803 url = item.get('url') or item.get('review_url')
804 if not url:
805 raise Exception('Dumped item %s does not specify url' % item)
806 output[url] = dict(
807 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
808 return output
809
810 class PythonObjectEncoder(json.JSONEncoder):
811 def default(self, obj): # pylint: disable=method-hidden
812 if isinstance(obj, datetime):
813 return obj.isoformat()
814 if isinstance(obj, set):
815 return list(obj)
816 return json.JSONEncoder.default(self, obj)
817
818 output = {
819 'reviews': format_for_json_dump(self.reviews),
820 'changes': format_for_json_dump(self.changes),
821 'issues': format_for_json_dump(self.issues)
822 }
823 print json.dumps(output, indent=2, cls=PythonObjectEncoder)
824
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000825
826def main():
827 # Silence upload.py.
828 rietveld.upload.verbosity = 0
829
830 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
831 parser.add_option(
832 '-u', '--user', metavar='<email>',
833 default=os.environ.get('USER'),
834 help='Filter on user, default=%default')
835 parser.add_option(
836 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000837 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000838 parser.add_option(
839 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000840 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000841 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
842 relativedelta(months=2))
843 parser.add_option(
844 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000845 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000846 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
847 parser.add_option(
848 '-Y', '--this_year', action='store_true',
849 help='Use this year\'s dates')
850 parser.add_option(
851 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000852 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000853 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000854 '-W', '--last_week', action='count',
855 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000856 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000857 '-a', '--auth',
858 action='store_true',
859 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000860 parser.add_option(
861 '-d', '--deltas',
862 action='store_true',
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800863 help='Fetch deltas for changes.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100864 parser.add_option(
865 '--no-referenced-issues',
866 action='store_true',
867 help='Do not fetch issues referenced by owned changes. Useful in '
868 'combination with --changes-by-issue when you only want to list '
Sergiy Byelozyorovb4475ab2018-03-23 17:49:34 +0100869 'issues that have also been modified in the same time period.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100870 parser.add_option(
871 '--skip-own-issues-without-changes',
872 action='store_true',
873 help='Skips listing own issues without changes when showing changes '
874 'grouped by referenced issue(s). See --changes-by-issue for more '
875 'details.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000876
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000877 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000878 'By default, all activity will be looked up and '
879 'printed. If any of these are specified, only '
880 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000881 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000882 '-c', '--changes',
883 action='store_true',
884 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000885 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000886 '-i', '--issues',
887 action='store_true',
888 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000889 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000890 '-r', '--reviews',
891 action='store_true',
892 help='Show reviews.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100893 activity_types_group.add_option(
894 '--changes-by-issue', action='store_true',
895 help='Show changes grouped by referenced issue(s).')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000896 parser.add_option_group(activity_types_group)
897
898 output_format_group = optparse.OptionGroup(parser, 'Output Format',
899 'By default, all activity will be printed in the '
900 'following format: {url} {title}. This can be '
901 'changed for either all activity types or '
902 'individually for each activity type. The format '
903 'is defined as documented for '
904 'string.format(...). The variables available for '
905 'all activity types are url, title and author. '
906 'Format options for specific activity types will '
907 'override the generic format.')
908 output_format_group.add_option(
909 '-f', '--output-format', metavar='<format>',
910 default=u'{url} {title}',
911 help='Specifies the format to use when printing all your activity.')
912 output_format_group.add_option(
913 '--output-format-changes', metavar='<format>',
914 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000915 help='Specifies the format to use when printing changes. Supports the '
916 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000917 output_format_group.add_option(
918 '--output-format-issues', metavar='<format>',
919 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000920 help='Specifies the format to use when printing issues. Supports the '
921 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000922 output_format_group.add_option(
923 '--output-format-reviews', metavar='<format>',
924 default=None,
925 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000926 output_format_group.add_option(
927 '--output-format-heading', metavar='<format>',
928 default=u'{heading}:',
929 help='Specifies the format to use when printing headings.')
930 output_format_group.add_option(
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100931 '--output-format-no-url', default='{title}',
932 help='Specifies the format to use when printing activity without url.')
933 output_format_group.add_option(
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000934 '-m', '--markdown', action='store_true',
935 help='Use markdown-friendly output (overrides --output-format '
936 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000937 output_format_group.add_option(
938 '-j', '--json', action='store_true',
939 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000940 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000941 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000942
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000943 parser.add_option(
944 '-v', '--verbose',
945 action='store_const',
946 dest='verbosity',
947 default=logging.WARN,
948 const=logging.INFO,
949 help='Output extra informational messages.'
950 )
951 parser.add_option(
952 '-q', '--quiet',
953 action='store_const',
954 dest='verbosity',
955 const=logging.ERROR,
956 help='Suppress non-error messages.'
957 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000958 parser.add_option(
959 '-o', '--output', metavar='<file>',
960 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000961
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000962 # Remove description formatting
963 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -0800964 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000965
966 options, args = parser.parse_args()
967 options.local_user = os.environ.get('USER')
968 if args:
969 parser.error('Args unsupported')
970 if not options.user:
971 parser.error('USER is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000972 options.user = username(options.user)
973
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000974 logging.basicConfig(level=options.verbosity)
975
976 # python-keyring provides easy access to the system keyring.
977 try:
978 import keyring # pylint: disable=unused-import,unused-variable,F0401
979 except ImportError:
980 logging.warning('Consider installing python-keyring')
981
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000982 if not options.begin:
983 if options.last_quarter:
984 begin, end = quarter_begin, quarter_end
985 elif options.this_year:
986 begin, end = get_year_of(datetime.today())
987 elif options.week_of:
988 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000989 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000990 begin, end = (get_week_of(datetime.today() -
991 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000992 else:
993 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
994 else:
Daniel Cheng4b37ce62017-09-07 12:00:02 -0700995 begin = dateutil.parser.parse(options.begin)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000996 if options.end:
Daniel Cheng4b37ce62017-09-07 12:00:02 -0700997 end = dateutil.parser.parse(options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000998 else:
999 end = datetime.today()
1000 options.begin, options.end = begin, end
1001
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +00001002 if options.markdown:
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +00001003 options.output_format_heading = '### {heading}\n'
1004 options.output_format = ' * [{title}]({url})'
1005 options.output_format_no_url = ' * {title}'
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001006 logging.info('Searching for activity by %s', options.user)
1007 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001008
1009 my_activity = MyActivity(options)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001010 my_activity.show_progress('Loading data')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001011
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001012 if not (options.changes or options.reviews or options.issues or
1013 options.changes_by_issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001014 options.changes = True
1015 options.issues = True
1016 options.reviews = True
1017
1018 # First do any required authentication so none of the user interaction has to
1019 # wait for actual work.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001020 if options.changes or options.changes_by_issue:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001021 my_activity.auth_for_changes()
1022 if options.reviews:
1023 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001024
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001025 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001026
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001027 try:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001028 if options.changes or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001029 my_activity.get_changes()
1030 if options.reviews:
1031 my_activity.get_reviews()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001032 if options.issues or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001033 my_activity.get_issues()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001034 if not options.no_referenced_issues:
1035 my_activity.get_referenced_issues()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001036 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001037 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001038
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001039 my_activity.show_progress('\n')
1040
Vadim Bendebury8de38002018-05-14 19:02:55 -07001041 my_activity.print_access_errors()
1042
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001043 output_file = None
1044 try:
1045 if options.output:
1046 output_file = open(options.output, 'w')
1047 logging.info('Printing output to "%s"', options.output)
1048 sys.stdout = output_file
1049 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -07001050 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001051 else:
1052 if options.json:
1053 my_activity.dump_json()
1054 else:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001055 if options.changes:
1056 my_activity.print_changes()
1057 if options.reviews:
1058 my_activity.print_reviews()
1059 if options.issues:
1060 my_activity.print_issues()
1061 if options.changes_by_issue:
1062 my_activity.print_changes_by_issue(
1063 options.skip_own_issues_without_changes)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001064 finally:
1065 if output_file:
1066 logging.info('Done printing to file.')
1067 sys.stdout = sys.__stdout__
1068 output_file.close()
1069
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001070 return 0
1071
1072
1073if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001074 # Fix encoding to support non-ascii issue titles.
1075 fix_encoding.fix_encoding()
1076
sbc@chromium.org013731e2015-02-26 18:28:43 +00001077 try:
1078 sys.exit(main())
1079 except KeyboardInterrupt:
1080 sys.stderr.write('interrupted\n')
1081 sys.exit(1)