blob: 418d8db0aad0d8dbce6be692d2257cd0d61b72b1 [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]
Peter K. Lee9342ac02018-08-28 21:03:51 +0000378 # TODO(cjhopman): Should abandoned changes be filtered out when
379 # merged_only is not enabled?
380 if self.options.merged_only:
381 filters.append('status:merged')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000382
Aaron Gable2979a872017-09-05 17:38:32 -0700383 issues = self.gerrit_changes_over_rest(instance, filters)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100384 self.show_progress()
Aaron Gable2979a872017-09-05 17:38:32 -0700385 issues = [self.process_gerrit_issue(instance, issue)
386 for issue in issues]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000387
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
Aaron Gable2979a872017-09-05 17:38:32 -0700393 def process_gerrit_issue(self, instance, issue):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000394 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000395 if self.options.deltas:
396 ret['delta'] = DefaultFormatter().format(
397 '+{insertions},-{deletions}',
398 **issue)
399 ret['status'] = issue['status']
deymo@chromium.org6c039202013-09-12 12:28:12 +0000400 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700401 protocol = instance.get('short_url_protocol', 'http')
402 url = instance['shorturl']
403 else:
404 protocol = 'https'
405 url = instance['url']
406 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
407
deymo@chromium.org6c039202013-09-12 12:28:12 +0000408 ret['header'] = issue['subject']
Don Garrett2ebf9fd2018-08-06 15:14:00 +0000409 ret['owner'] = issue['owner'].get('email', '')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000410 ret['author'] = ret['owner']
411 ret['created'] = datetime_from_gerrit(issue['created'])
412 ret['modified'] = datetime_from_gerrit(issue['updated'])
413 if 'messages' in issue:
Aaron Gable2979a872017-09-05 17:38:32 -0700414 ret['replies'] = self.process_gerrit_issue_replies(issue['messages'])
deymo@chromium.org6c039202013-09-12 12:28:12 +0000415 else:
416 ret['replies'] = []
417 ret['reviewers'] = set(r['author'] for r in ret['replies'])
418 ret['reviewers'].discard(ret['author'])
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000419 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000420 return ret
421
422 @staticmethod
Aaron Gable2979a872017-09-05 17:38:32 -0700423 def process_gerrit_issue_replies(replies):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000424 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000425 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
426 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000427 for reply in replies:
428 ret.append({
429 'author': reply['author']['email'],
430 'created': datetime_from_gerrit(reply['date']),
431 'content': reply['message'],
432 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000433 return ret
434
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100435 def monorail_get_auth_http(self):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000436 auth_config = auth.extract_auth_config_from_options(self.options)
437 authenticator = auth.get_authenticator_for_host(
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000438 'bugs.chromium.org', auth_config)
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100439 return authenticator.authorize(httplib2.Http())
440
441 def filter_modified_monorail_issue(self, issue):
442 """Precisely checks if an issue has been modified in the time range.
443
444 This fetches all issue comments to check if the issue has been modified in
445 the time range specified by user. This is needed because monorail only
446 allows filtering by last updated and published dates, which is not
447 sufficient to tell whether a given issue has been modified at some specific
448 time range. Any update to the issue is a reported as comment on Monorail.
449
450 Args:
451 issue: Issue dict as returned by monorail_query_issues method. In
452 particular, must have a key 'uid' formatted as 'project:issue_id'.
453
454 Returns:
455 Passed issue if modified, None otherwise.
456 """
457 http = self.monorail_get_auth_http()
458 project, issue_id = issue['uid'].split(':')
459 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
460 '/%s/issues/%s/comments?maxResults=10000') % (project, issue_id)
461 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100462 self.show_progress()
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100463 content = json.loads(body)
464 if not content:
465 logging.error('Unable to parse %s response from monorail.', project)
466 return issue
467
468 for item in content.get('items', []):
469 comment_published = datetime_from_monorail(item['published'])
470 if self.filter_modified(comment_published):
471 return issue
472
473 return None
474
475 def monorail_query_issues(self, project, query):
476 http = self.monorail_get_auth_http()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000477 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100478 '/%s/issues') % project
479 query_data = urllib.urlencode(query)
480 url = url + '?' + query_data
481 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100482 self.show_progress()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100483 content = json.loads(body)
484 if not content:
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100485 logging.error('Unable to parse %s response from monorail.', project)
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100486 return []
487
488 issues = []
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100489 project_config = monorail_projects.get(project, {})
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100490 for item in content.get('items', []):
491 if project_config.get('shorturl'):
492 protocol = project_config.get('short_url_protocol', 'http')
493 item_url = '%s://%s/%d' % (
494 protocol, project_config['shorturl'], item['id'])
495 else:
496 item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
497 project, item['id'])
498 issue = {
499 'uid': '%s:%s' % (project, item['id']),
500 'header': item['title'],
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100501 'created': datetime_from_monorail(item['published']),
502 'modified': datetime_from_monorail(item['updated']),
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100503 'author': item['author']['name'],
504 'url': item_url,
505 'comments': [],
506 'status': item['status'],
507 'labels': [],
508 'components': []
509 }
510 if 'owner' in item:
511 issue['owner'] = item['owner']['name']
512 else:
513 issue['owner'] = 'None'
514 if 'labels' in item:
515 issue['labels'] = item['labels']
516 if 'components' in item:
517 issue['components'] = item['components']
518 issues.append(issue)
519
520 return issues
521
522 def monorail_issue_search(self, project):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000523 epoch = datetime.utcfromtimestamp(0)
524 user_str = '%s@chromium.org' % self.user
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000525
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100526 issues = self.monorail_query_issues(project, {
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000527 'maxResults': 10000,
528 'q': user_str,
529 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
530 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000531 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000532
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100533 return [
534 issue for issue in issues
535 if issue['author'] == user_str or issue['owner'] == user_str]
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000536
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100537 def monorail_get_issues(self, project, issue_ids):
538 return self.monorail_query_issues(project, {
539 'maxResults': 10000,
540 'q': 'id:%s' % ','.join(issue_ids)
541 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000542
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000543 def print_heading(self, heading):
544 print
545 print self.options.output_format_heading.format(heading=heading)
546
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000547 def match(self, author):
548 if '@' in self.user:
549 return author == self.user
550 return author.startswith(self.user + '@')
551
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000552 def print_change(self, change):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000553 activity = len([
554 reply
555 for reply in change['replies']
556 if self.match(reply['author'])
557 ])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000558 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000559 'created': change['created'].date().isoformat(),
560 'modified': change['modified'].date().isoformat(),
561 'reviewers': ', '.join(change['reviewers']),
562 'status': change['status'],
563 'activity': activity,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000564 }
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000565 if self.options.deltas:
566 optional_values['delta'] = change['delta']
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 = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000577 'created': issue['created'].date().isoformat(),
578 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000579 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000580 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000581 }
582 self.print_generic(self.options.output_format,
583 self.options.output_format_issues,
584 issue['header'],
585 issue['url'],
586 issue['author'],
587 optional_values)
588
589 def print_review(self, review):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000590 activity = len([
591 reply
592 for reply in review['replies']
593 if self.match(reply['author'])
594 ])
595 optional_values = {
596 'created': review['created'].date().isoformat(),
597 'modified': review['modified'].date().isoformat(),
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800598 'status': review['status'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000599 'activity': activity,
600 }
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800601 if self.options.deltas:
602 optional_values['delta'] = review['delta']
603
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000604 self.print_generic(self.options.output_format,
605 self.options.output_format_reviews,
606 review['header'],
607 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000608 review['author'],
609 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000610
611 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000612 def print_generic(default_fmt, specific_fmt,
613 title, url, author,
614 optional_values=None):
615 output_format = specific_fmt if specific_fmt is not None else default_fmt
616 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000617 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000618 'title': title,
619 'url': url,
620 'author': author,
621 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000622 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000623 values.update(optional_values)
624 print DefaultFormatter().format(output_format, **values).encode(
625 sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000626
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000627
628 def filter_issue(self, issue, should_filter_by_user=True):
629 def maybe_filter_username(email):
630 return not should_filter_by_user or username(email) == self.user
631 if (maybe_filter_username(issue['author']) and
632 self.filter_modified(issue['created'])):
633 return True
634 if (maybe_filter_username(issue['owner']) and
635 (self.filter_modified(issue['created']) or
636 self.filter_modified(issue['modified']))):
637 return True
638 for reply in issue['replies']:
639 if self.filter_modified(reply['created']):
640 if not should_filter_by_user:
641 break
642 if (username(reply['author']) == self.user
643 or (self.user + '@') in reply['content']):
644 break
645 else:
646 return False
647 return True
648
649 def filter_modified(self, modified):
650 return self.modified_after < modified and modified < self.modified_before
651
652 def auth_for_changes(self):
653 #TODO(cjhopman): Move authentication check for getting changes here.
654 pass
655
656 def auth_for_reviews(self):
657 # Reviews use all the same instances as changes so no authentication is
658 # required.
659 pass
660
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000661 def get_changes(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100662 num_instances = len(rietveld_instances) + len(gerrit_instances)
663 with contextlib.closing(ThreadPool(num_instances)) as pool:
664 rietveld_changes = pool.map_async(
665 lambda instance: self.rietveld_search(instance, owner=self.user),
666 rietveld_instances)
667 gerrit_changes = pool.map_async(
668 lambda instance: self.gerrit_search(instance, owner=self.user),
669 gerrit_instances)
670 rietveld_changes = itertools.chain.from_iterable(rietveld_changes.get())
671 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
672 self.changes = list(rietveld_changes) + list(gerrit_changes)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000673
674 def print_changes(self):
675 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000676 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000677 for change in self.changes:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100678 self.print_change(change)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000679
Vadim Bendebury8de38002018-05-14 19:02:55 -0700680 def print_access_errors(self):
681 if self.access_errors:
Ryan Harrison398fb442018-05-22 12:05:26 -0400682 logging.error('Access Errors:')
683 for error in self.access_errors:
684 logging.error(error.rstrip())
Vadim Bendebury8de38002018-05-14 19:02:55 -0700685
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000686 def get_reviews(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100687 num_instances = len(rietveld_instances) + len(gerrit_instances)
688 with contextlib.closing(ThreadPool(num_instances)) as pool:
689 rietveld_reviews = pool.map_async(
690 lambda instance: self.rietveld_search(instance, reviewer=self.user),
691 rietveld_instances)
692 gerrit_reviews = pool.map_async(
693 lambda instance: self.gerrit_search(instance, reviewer=self.user),
694 gerrit_instances)
695 rietveld_reviews = itertools.chain.from_iterable(rietveld_reviews.get())
696 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
jdoerrie356c2882018-07-23 10:02:02 +0000697 gerrit_reviews = [r for r in gerrit_reviews if not self.match(r['owner'])]
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100698 self.reviews = list(rietveld_reviews) + list(gerrit_reviews)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000699
700 def print_reviews(self):
701 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000702 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000703 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000704 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000705
706 def get_issues(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100707 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
708 monorail_issues = pool.map(
709 self.monorail_issue_search, monorail_projects.keys())
710 monorail_issues = list(itertools.chain.from_iterable(monorail_issues))
711
Vadim Bendeburycbf02042018-05-14 17:46:15 -0700712 if not monorail_issues:
713 return
714
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100715 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
716 filtered_issues = pool.map(
717 self.filter_modified_monorail_issue, monorail_issues)
718 self.issues = [issue for issue in filtered_issues if issue]
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100719
720 def get_referenced_issues(self):
721 if not self.issues:
722 self.get_issues()
723
724 if not self.changes:
725 self.get_changes()
726
727 referenced_issue_uids = set(itertools.chain.from_iterable(
728 change['bugs'] for change in self.changes))
729 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
730 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
731
732 missing_issues_by_project = collections.defaultdict(list)
733 for issue_uid in missing_issue_uids:
734 project, issue_id = issue_uid.split(':')
735 missing_issues_by_project[project].append(issue_id)
736
737 for project, issue_ids in missing_issues_by_project.iteritems():
738 self.referenced_issues += self.monorail_get_issues(project, issue_ids)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000739
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000740 def print_issues(self):
741 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000742 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000743 for issue in self.issues:
744 self.print_issue(issue)
745
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100746 def print_changes_by_issue(self, skip_empty_own):
747 if not self.issues or not self.changes:
748 return
749
750 self.print_heading('Changes by referenced issue(s)')
751 issues = {issue['uid']: issue for issue in self.issues}
752 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
753 changes_by_issue_uid = collections.defaultdict(list)
754 changes_by_ref_issue_uid = collections.defaultdict(list)
755 changes_without_issue = []
756 for change in self.changes:
757 added = False
Andrii Shyshkalov483580b2018-07-02 06:55:10 +0000758 for issue_uid in change['bugs']:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100759 if issue_uid in issues:
760 changes_by_issue_uid[issue_uid].append(change)
761 added = True
762 if issue_uid in ref_issues:
763 changes_by_ref_issue_uid[issue_uid].append(change)
764 added = True
765 if not added:
766 changes_without_issue.append(change)
767
768 # Changes referencing own issues.
769 for issue_uid in issues:
770 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
771 self.print_issue(issues[issue_uid])
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +0000772 if changes_by_issue_uid[issue_uid]:
773 print
774 for change in changes_by_issue_uid[issue_uid]:
775 print ' ', # this prints one space due to comma, but no newline
776 self.print_change(change)
777 print
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100778
779 # Changes referencing others' issues.
780 for issue_uid in ref_issues:
781 assert changes_by_ref_issue_uid[issue_uid]
782 self.print_issue(ref_issues[issue_uid])
783 for change in changes_by_ref_issue_uid[issue_uid]:
784 print '', # this prints one space due to comma, but no newline
785 self.print_change(change)
786
787 # Changes referencing no issues.
788 if changes_without_issue:
789 print self.options.output_format_no_url.format(title='Other changes')
790 for change in changes_without_issue:
791 print '', # this prints one space due to comma, but no newline
792 self.print_change(change)
793
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000794 def print_activity(self):
795 self.print_changes()
796 self.print_reviews()
797 self.print_issues()
798
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000799 def dump_json(self, ignore_keys=None):
800 if ignore_keys is None:
801 ignore_keys = ['replies']
802
803 def format_for_json_dump(in_array):
804 output = {}
805 for item in in_array:
806 url = item.get('url') or item.get('review_url')
807 if not url:
808 raise Exception('Dumped item %s does not specify url' % item)
809 output[url] = dict(
810 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
811 return output
812
813 class PythonObjectEncoder(json.JSONEncoder):
814 def default(self, obj): # pylint: disable=method-hidden
815 if isinstance(obj, datetime):
816 return obj.isoformat()
817 if isinstance(obj, set):
818 return list(obj)
819 return json.JSONEncoder.default(self, obj)
820
821 output = {
822 'reviews': format_for_json_dump(self.reviews),
823 'changes': format_for_json_dump(self.changes),
824 'issues': format_for_json_dump(self.issues)
825 }
826 print json.dumps(output, indent=2, cls=PythonObjectEncoder)
827
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000828
829def main():
830 # Silence upload.py.
831 rietveld.upload.verbosity = 0
832
833 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
834 parser.add_option(
835 '-u', '--user', metavar='<email>',
836 default=os.environ.get('USER'),
837 help='Filter on user, default=%default')
838 parser.add_option(
839 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000840 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000841 parser.add_option(
842 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000843 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000844 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
845 relativedelta(months=2))
846 parser.add_option(
847 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000848 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000849 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
850 parser.add_option(
851 '-Y', '--this_year', action='store_true',
852 help='Use this year\'s dates')
853 parser.add_option(
854 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000855 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000856 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000857 '-W', '--last_week', action='count',
858 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000859 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000860 '-a', '--auth',
861 action='store_true',
862 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000863 parser.add_option(
864 '-d', '--deltas',
865 action='store_true',
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800866 help='Fetch deltas for changes.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100867 parser.add_option(
868 '--no-referenced-issues',
869 action='store_true',
870 help='Do not fetch issues referenced by owned changes. Useful in '
871 'combination with --changes-by-issue when you only want to list '
Sergiy Byelozyorovb4475ab2018-03-23 17:49:34 +0100872 'issues that have also been modified in the same time period.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100873 parser.add_option(
874 '--skip-own-issues-without-changes',
875 action='store_true',
876 help='Skips listing own issues without changes when showing changes '
877 'grouped by referenced issue(s). See --changes-by-issue for more '
878 'details.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000879
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000880 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000881 'By default, all activity will be looked up and '
882 'printed. If any of these are specified, only '
883 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000884 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000885 '-c', '--changes',
886 action='store_true',
887 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000888 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000889 '-i', '--issues',
890 action='store_true',
891 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000892 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000893 '-r', '--reviews',
894 action='store_true',
895 help='Show reviews.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100896 activity_types_group.add_option(
897 '--changes-by-issue', action='store_true',
898 help='Show changes grouped by referenced issue(s).')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000899 parser.add_option_group(activity_types_group)
900
901 output_format_group = optparse.OptionGroup(parser, 'Output Format',
902 'By default, all activity will be printed in the '
903 'following format: {url} {title}. This can be '
904 'changed for either all activity types or '
905 'individually for each activity type. The format '
906 'is defined as documented for '
907 'string.format(...). The variables available for '
908 'all activity types are url, title and author. '
909 'Format options for specific activity types will '
910 'override the generic format.')
911 output_format_group.add_option(
912 '-f', '--output-format', metavar='<format>',
913 default=u'{url} {title}',
914 help='Specifies the format to use when printing all your activity.')
915 output_format_group.add_option(
916 '--output-format-changes', metavar='<format>',
917 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000918 help='Specifies the format to use when printing changes. Supports the '
919 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000920 output_format_group.add_option(
921 '--output-format-issues', metavar='<format>',
922 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000923 help='Specifies the format to use when printing issues. Supports the '
924 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000925 output_format_group.add_option(
926 '--output-format-reviews', metavar='<format>',
927 default=None,
928 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000929 output_format_group.add_option(
930 '--output-format-heading', metavar='<format>',
931 default=u'{heading}:',
932 help='Specifies the format to use when printing headings.')
933 output_format_group.add_option(
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100934 '--output-format-no-url', default='{title}',
935 help='Specifies the format to use when printing activity without url.')
936 output_format_group.add_option(
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000937 '-m', '--markdown', action='store_true',
938 help='Use markdown-friendly output (overrides --output-format '
939 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000940 output_format_group.add_option(
941 '-j', '--json', action='store_true',
942 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000943 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000944 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000945
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000946 parser.add_option(
947 '-v', '--verbose',
948 action='store_const',
949 dest='verbosity',
950 default=logging.WARN,
951 const=logging.INFO,
952 help='Output extra informational messages.'
953 )
954 parser.add_option(
955 '-q', '--quiet',
956 action='store_const',
957 dest='verbosity',
958 const=logging.ERROR,
959 help='Suppress non-error messages.'
960 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000961 parser.add_option(
Peter K. Lee9342ac02018-08-28 21:03:51 +0000962 '-M', '--merged-only',
963 action='store_true',
964 dest='merged_only',
965 default=False,
966 help='Shows only changes that have been merged.')
967 parser.add_option(
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000968 '-o', '--output', metavar='<file>',
969 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000970
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000971 # Remove description formatting
972 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -0800973 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000974
975 options, args = parser.parse_args()
976 options.local_user = os.environ.get('USER')
977 if args:
978 parser.error('Args unsupported')
979 if not options.user:
980 parser.error('USER is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000981 options.user = username(options.user)
982
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000983 logging.basicConfig(level=options.verbosity)
984
985 # python-keyring provides easy access to the system keyring.
986 try:
987 import keyring # pylint: disable=unused-import,unused-variable,F0401
988 except ImportError:
989 logging.warning('Consider installing python-keyring')
990
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000991 if not options.begin:
992 if options.last_quarter:
993 begin, end = quarter_begin, quarter_end
994 elif options.this_year:
995 begin, end = get_year_of(datetime.today())
996 elif options.week_of:
997 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000998 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000999 begin, end = (get_week_of(datetime.today() -
1000 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001001 else:
1002 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
1003 else:
Daniel Cheng4b37ce62017-09-07 12:00:02 -07001004 begin = dateutil.parser.parse(options.begin)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001005 if options.end:
Daniel Cheng4b37ce62017-09-07 12:00:02 -07001006 end = dateutil.parser.parse(options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001007 else:
1008 end = datetime.today()
1009 options.begin, options.end = begin, end
1010
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +00001011 if options.markdown:
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +00001012 options.output_format_heading = '### {heading}\n'
1013 options.output_format = ' * [{title}]({url})'
1014 options.output_format_no_url = ' * {title}'
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001015 logging.info('Searching for activity by %s', options.user)
1016 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001017
1018 my_activity = MyActivity(options)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001019 my_activity.show_progress('Loading data')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001020
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001021 if not (options.changes or options.reviews or options.issues or
1022 options.changes_by_issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001023 options.changes = True
1024 options.issues = True
1025 options.reviews = True
1026
1027 # First do any required authentication so none of the user interaction has to
1028 # wait for actual work.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001029 if options.changes or options.changes_by_issue:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001030 my_activity.auth_for_changes()
1031 if options.reviews:
1032 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001033
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001034 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001035
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001036 try:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001037 if options.changes or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001038 my_activity.get_changes()
1039 if options.reviews:
1040 my_activity.get_reviews()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001041 if options.issues or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001042 my_activity.get_issues()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001043 if not options.no_referenced_issues:
1044 my_activity.get_referenced_issues()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001045 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001046 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001047
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001048 my_activity.show_progress('\n')
1049
Vadim Bendebury8de38002018-05-14 19:02:55 -07001050 my_activity.print_access_errors()
1051
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001052 output_file = None
1053 try:
1054 if options.output:
1055 output_file = open(options.output, 'w')
1056 logging.info('Printing output to "%s"', options.output)
1057 sys.stdout = output_file
1058 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -07001059 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001060 else:
1061 if options.json:
1062 my_activity.dump_json()
1063 else:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001064 if options.changes:
1065 my_activity.print_changes()
1066 if options.reviews:
1067 my_activity.print_reviews()
1068 if options.issues:
1069 my_activity.print_issues()
1070 if options.changes_by_issue:
1071 my_activity.print_changes_by_issue(
1072 options.skip_own_issues_without_changes)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001073 finally:
1074 if output_file:
1075 logging.info('Done printing to file.')
1076 sys.stdout = sys.__stdout__
1077 output_file.close()
1078
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001079 return 0
1080
1081
1082if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001083 # Fix encoding to support non-ascii issue titles.
1084 fix_encoding.fix_encoding()
1085
sbc@chromium.org013731e2015-02-26 18:28:43 +00001086 try:
1087 sys.exit(main())
1088 except KeyboardInterrupt:
1089 sys.stderr.write('interrupted\n')
1090 sys.exit(1)