blob: 3745cf014540a43c3a1d75990c8151ab006c9b20 [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 {
112 'url': 'pdfium-review.googlesource.com',
113 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000114]
115
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100116monorail_projects = {
117 'chromium': {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000118 'shorturl': 'crbug.com',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700119 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000120 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100121 'google-breakpad': {},
122 'gyp': {},
123 'skia': {},
124 'pdfium': {
Ryan Harrison897602a2017-09-18 16:23:41 -0400125 'shorturl': 'crbug.com/pdfium',
126 'short_url_protocol': 'https',
127 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100128 'v8': {
129 'shorturl': 'crbug.com/v8',
130 'short_url_protocol': 'https',
131 },
132}
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000133
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000134def username(email):
135 """Keeps the username of an email address."""
136 return email and email.split('@', 1)[0]
137
138
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000139def datetime_to_midnight(date):
140 return date - timedelta(hours=date.hour, minutes=date.minute,
141 seconds=date.second, microseconds=date.microsecond)
142
143
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000144def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000145 begin = (datetime_to_midnight(date) -
146 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000147 return begin, begin + relativedelta(months=3)
148
149
150def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000151 begin = (datetime_to_midnight(date) -
152 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000153 return begin, begin + relativedelta(years=1)
154
155
156def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000157 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000158 return begin, begin + timedelta(days=7)
159
160
161def get_yes_or_no(msg):
162 while True:
163 response = raw_input(msg + ' yes/no [no] ')
164 if response == 'y' or response == 'yes':
165 return True
166 elif not response or response == 'n' or response == 'no':
167 return False
168
169
deymo@chromium.org6c039202013-09-12 12:28:12 +0000170def datetime_from_gerrit(date_string):
171 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
172
173
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000174def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000175 try:
176 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
177 except ValueError:
178 # Sometimes rietveld returns a value without the milliseconds part, so we
179 # attempt to parse those cases as well.
180 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000181
182
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100183def datetime_from_monorail(date_string):
184 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000185
186
187class MyActivity(object):
188 def __init__(self, options):
189 self.options = options
190 self.modified_after = options.begin
191 self.modified_before = options.end
192 self.user = options.user
193 self.changes = []
194 self.reviews = []
195 self.issues = []
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100196 self.referenced_issues = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000197 self.check_cookies()
198 self.google_code_auth_token = None
Vadim Bendebury8de38002018-05-14 19:02:55 -0700199 self.access_errors = set()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000200
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100201 def show_progress(self, how='.'):
202 if sys.stdout.isatty():
203 sys.stdout.write(how)
204 sys.stdout.flush()
205
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000206 # Check the codereview cookie jar to determine which Rietveld instances to
207 # authenticate to.
208 def check_cookies(self):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000209 filtered_instances = []
210
211 def has_cookie(instance):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000212 auth_config = auth.extract_auth_config_from_options(self.options)
213 a = auth.get_authenticator_for_host(instance['url'], auth_config)
214 return a.has_cached_credentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000215
216 for instance in rietveld_instances:
217 instance['auth'] = has_cookie(instance)
218
219 if filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000220 logging.warning('No cookie found for the following Rietveld instance%s:',
221 's' if len(filtered_instances) > 1 else '')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000222 for instance in filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000223 logging.warning('\t' + instance['url'])
224 logging.warning('Use --auth if you would like to authenticate to them.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000225
226 def rietveld_search(self, instance, owner=None, reviewer=None):
227 if instance['requires_auth'] and not instance['auth']:
228 return []
229
230
231 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000232 auth_config = auth.extract_auth_config_from_options(self.options)
233 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000234
235 # See def search() in rietveld.py to see all the filters you can use.
236 query_modified_after = None
237
238 if instance['supports_owner_modified_query']:
239 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
240
241 # Rietveld does not allow search by both created_before and modified_after.
242 # (And some instances don't allow search by both owner and modified_after)
243 owner_email = None
244 reviewer_email = None
245 if owner:
246 owner_email = owner + '@' + instance['email_domain']
247 if reviewer:
248 reviewer_email = reviewer + '@' + instance['email_domain']
249 issues = remote.search(
250 owner=owner_email,
251 reviewer=reviewer_email,
252 modified_after=query_modified_after,
253 with_messages=True)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100254 self.show_progress()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000255
256 issues = filter(
257 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
258 issues)
259 issues = filter(
260 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
261 issues)
262
263 should_filter_by_user = True
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000264 issues = map(partial(self.process_rietveld_issue, remote, instance), issues)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000265 issues = filter(
266 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
267 issues)
268 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
269
270 return issues
271
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000272 def extract_bug_numbers_from_description(self, issue):
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000273 description = None
274
275 if 'description' in issue:
276 # Getting the description for Rietveld
277 description = issue['description']
278 elif 'revisions' in issue:
279 # Getting the description for REST Gerrit
280 revision = issue['revisions'][issue['current_revision']]
281 description = revision['commit']['message']
282
283 bugs = []
284 if description:
Nicolas Dossou-gbete903ea732017-07-10 16:46:59 +0100285 # Handle both "Bug: 99999" and "BUG=99999" bug notations
286 # Multiple bugs can be noted on a single line or in multiple ones.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100287 matches = re.findall(
288 r'BUG[=:]\s?((((?:[a-zA-Z0-9-]+:)?\d+)(,\s?)?)+)', description,
289 flags=re.IGNORECASE)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000290 if matches:
291 for match in matches:
292 bugs.extend(match[0].replace(' ', '').split(','))
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100293 # Add default chromium: prefix if none specified.
294 bugs = [bug if ':' in bug else 'chromium:%s' % bug for bug in bugs]
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000295
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000296 return sorted(set(bugs))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000297
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000298 def process_rietveld_issue(self, remote, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000299 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000300 if self.options.deltas:
301 patchset_props = remote.get_patchset_properties(
302 issue['issue'],
303 issue['patchsets'][-1])
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100304 self.show_progress()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000305 ret['delta'] = '+%d,-%d' % (
306 sum(f['num_added'] for f in patchset_props['files'].itervalues()),
307 sum(f['num_removed'] for f in patchset_props['files'].itervalues()))
308
309 if issue['landed_days_ago'] != 'unknown':
310 ret['status'] = 'committed'
311 elif issue['closed']:
312 ret['status'] = 'closed'
313 elif len(issue['reviewers']) and issue['all_required_reviewers_approved']:
314 ret['status'] = 'ready'
315 else:
316 ret['status'] = 'open'
317
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000318 ret['owner'] = issue['owner_email']
319 ret['author'] = ret['owner']
320
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000321 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000322
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000323 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700324 url = instance['shorturl']
325 protocol = instance.get('short_url_protocol', 'http')
326 else:
327 url = instance['url']
328 protocol = 'https'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000329
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700330 ret['review_url'] = '%s://%s/%d' % (protocol, url, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000331
332 # Rietveld sometimes has '\r\n' instead of '\n'.
333 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000334
335 ret['modified'] = datetime_from_rietveld(issue['modified'])
336 ret['created'] = datetime_from_rietveld(issue['created'])
337 ret['replies'] = self.process_rietveld_replies(issue['messages'])
338
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000339 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000340 ret['landed_days_ago'] = issue['landed_days_ago']
341
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000342 return ret
343
344 @staticmethod
345 def process_rietveld_replies(replies):
346 ret = []
347 for reply in replies:
348 r = {}
349 r['author'] = reply['sender']
350 r['created'] = datetime_from_rietveld(reply['date'])
351 r['content'] = ''
352 ret.append(r)
353 return ret
354
Vadim Bendebury8de38002018-05-14 19:02:55 -0700355 def gerrit_changes_over_rest(self, instance, filters):
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200356 # Convert the "key:value" filter to a list of (key, value) pairs.
357 req = list(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000358 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000359 # Instantiate the generator to force all the requests now and catch the
360 # errors here.
361 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000362 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS',
363 'CURRENT_REVISION', 'CURRENT_COMMIT']))
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000364 except gerrit_util.GerritError, e:
Vadim Bendebury8de38002018-05-14 19:02:55 -0700365 error_message = 'Looking up %r: %s' % (instance['url'], e)
366 if error_message not in self.access_errors:
367 self.access_errors.add(error_message)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000368 return []
369
deymo@chromium.org6c039202013-09-12 12:28:12 +0000370 def gerrit_search(self, instance, owner=None, reviewer=None):
371 max_age = datetime.today() - self.modified_after
372 max_age = max_age.days * 24 * 3600 + max_age.seconds
373 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
374 filters = ['-age:%ss' % max_age, user_filter]
375
Aaron Gable2979a872017-09-05 17:38:32 -0700376 issues = self.gerrit_changes_over_rest(instance, filters)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100377 self.show_progress()
Aaron Gable2979a872017-09-05 17:38:32 -0700378 issues = [self.process_gerrit_issue(instance, issue)
379 for issue in issues]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000380
381 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000382 issues = filter(self.filter_issue, issues)
383 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
384
385 return issues
386
Aaron Gable2979a872017-09-05 17:38:32 -0700387 def process_gerrit_issue(self, instance, issue):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000388 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000389 if self.options.deltas:
390 ret['delta'] = DefaultFormatter().format(
391 '+{insertions},-{deletions}',
392 **issue)
393 ret['status'] = issue['status']
deymo@chromium.org6c039202013-09-12 12:28:12 +0000394 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700395 protocol = instance.get('short_url_protocol', 'http')
396 url = instance['shorturl']
397 else:
398 protocol = 'https'
399 url = instance['url']
400 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
401
deymo@chromium.org6c039202013-09-12 12:28:12 +0000402 ret['header'] = issue['subject']
Don Garrett2ebf9fd2018-08-06 15:14:00 +0000403 ret['owner'] = issue['owner'].get('email', '')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000404 ret['author'] = ret['owner']
405 ret['created'] = datetime_from_gerrit(issue['created'])
406 ret['modified'] = datetime_from_gerrit(issue['updated'])
407 if 'messages' in issue:
Aaron Gable2979a872017-09-05 17:38:32 -0700408 ret['replies'] = self.process_gerrit_issue_replies(issue['messages'])
deymo@chromium.org6c039202013-09-12 12:28:12 +0000409 else:
410 ret['replies'] = []
411 ret['reviewers'] = set(r['author'] for r in ret['replies'])
412 ret['reviewers'].discard(ret['author'])
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000413 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000414 return ret
415
416 @staticmethod
Aaron Gable2979a872017-09-05 17:38:32 -0700417 def process_gerrit_issue_replies(replies):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000418 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000419 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
420 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000421 for reply in replies:
422 ret.append({
423 'author': reply['author']['email'],
424 'created': datetime_from_gerrit(reply['date']),
425 'content': reply['message'],
426 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000427 return ret
428
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100429 def monorail_get_auth_http(self):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000430 auth_config = auth.extract_auth_config_from_options(self.options)
431 authenticator = auth.get_authenticator_for_host(
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000432 'bugs.chromium.org', auth_config)
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100433 return authenticator.authorize(httplib2.Http())
434
435 def filter_modified_monorail_issue(self, issue):
436 """Precisely checks if an issue has been modified in the time range.
437
438 This fetches all issue comments to check if the issue has been modified in
439 the time range specified by user. This is needed because monorail only
440 allows filtering by last updated and published dates, which is not
441 sufficient to tell whether a given issue has been modified at some specific
442 time range. Any update to the issue is a reported as comment on Monorail.
443
444 Args:
445 issue: Issue dict as returned by monorail_query_issues method. In
446 particular, must have a key 'uid' formatted as 'project:issue_id'.
447
448 Returns:
449 Passed issue if modified, None otherwise.
450 """
451 http = self.monorail_get_auth_http()
452 project, issue_id = issue['uid'].split(':')
453 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
454 '/%s/issues/%s/comments?maxResults=10000') % (project, issue_id)
455 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100456 self.show_progress()
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100457 content = json.loads(body)
458 if not content:
459 logging.error('Unable to parse %s response from monorail.', project)
460 return issue
461
462 for item in content.get('items', []):
463 comment_published = datetime_from_monorail(item['published'])
464 if self.filter_modified(comment_published):
465 return issue
466
467 return None
468
469 def monorail_query_issues(self, project, query):
470 http = self.monorail_get_auth_http()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000471 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100472 '/%s/issues') % project
473 query_data = urllib.urlencode(query)
474 url = url + '?' + query_data
475 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100476 self.show_progress()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100477 content = json.loads(body)
478 if not content:
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100479 logging.error('Unable to parse %s response from monorail.', project)
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100480 return []
481
482 issues = []
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100483 project_config = monorail_projects.get(project, {})
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100484 for item in content.get('items', []):
485 if project_config.get('shorturl'):
486 protocol = project_config.get('short_url_protocol', 'http')
487 item_url = '%s://%s/%d' % (
488 protocol, project_config['shorturl'], item['id'])
489 else:
490 item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
491 project, item['id'])
492 issue = {
493 'uid': '%s:%s' % (project, item['id']),
494 'header': item['title'],
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100495 'created': datetime_from_monorail(item['published']),
496 'modified': datetime_from_monorail(item['updated']),
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100497 'author': item['author']['name'],
498 'url': item_url,
499 'comments': [],
500 'status': item['status'],
501 'labels': [],
502 'components': []
503 }
504 if 'owner' in item:
505 issue['owner'] = item['owner']['name']
506 else:
507 issue['owner'] = 'None'
508 if 'labels' in item:
509 issue['labels'] = item['labels']
510 if 'components' in item:
511 issue['components'] = item['components']
512 issues.append(issue)
513
514 return issues
515
516 def monorail_issue_search(self, project):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000517 epoch = datetime.utcfromtimestamp(0)
518 user_str = '%s@chromium.org' % self.user
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000519
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100520 issues = self.monorail_query_issues(project, {
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000521 'maxResults': 10000,
522 'q': user_str,
523 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
524 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000525 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000526
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100527 return [
528 issue for issue in issues
529 if issue['author'] == user_str or issue['owner'] == user_str]
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000530
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100531 def monorail_get_issues(self, project, issue_ids):
532 return self.monorail_query_issues(project, {
533 'maxResults': 10000,
534 'q': 'id:%s' % ','.join(issue_ids)
535 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000536
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000537 def print_heading(self, heading):
538 print
539 print self.options.output_format_heading.format(heading=heading)
540
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000541 def match(self, author):
542 if '@' in self.user:
543 return author == self.user
544 return author.startswith(self.user + '@')
545
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000546 def print_change(self, change):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000547 activity = len([
548 reply
549 for reply in change['replies']
550 if self.match(reply['author'])
551 ])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000552 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000553 'created': change['created'].date().isoformat(),
554 'modified': change['modified'].date().isoformat(),
555 'reviewers': ', '.join(change['reviewers']),
556 'status': change['status'],
557 'activity': activity,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000558 }
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000559 if self.options.deltas:
560 optional_values['delta'] = change['delta']
561
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000562 self.print_generic(self.options.output_format,
563 self.options.output_format_changes,
564 change['header'],
565 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000566 change['author'],
567 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000568
569 def print_issue(self, issue):
570 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000571 'created': issue['created'].date().isoformat(),
572 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000573 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000574 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000575 }
576 self.print_generic(self.options.output_format,
577 self.options.output_format_issues,
578 issue['header'],
579 issue['url'],
580 issue['author'],
581 optional_values)
582
583 def print_review(self, review):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000584 activity = len([
585 reply
586 for reply in review['replies']
587 if self.match(reply['author'])
588 ])
589 optional_values = {
590 'created': review['created'].date().isoformat(),
591 'modified': review['modified'].date().isoformat(),
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800592 'status': review['status'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000593 'activity': activity,
594 }
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800595 if self.options.deltas:
596 optional_values['delta'] = review['delta']
597
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000598 self.print_generic(self.options.output_format,
599 self.options.output_format_reviews,
600 review['header'],
601 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000602 review['author'],
603 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000604
605 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000606 def print_generic(default_fmt, specific_fmt,
607 title, url, author,
608 optional_values=None):
609 output_format = specific_fmt if specific_fmt is not None else default_fmt
610 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000611 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000612 'title': title,
613 'url': url,
614 'author': author,
615 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000616 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000617 values.update(optional_values)
618 print DefaultFormatter().format(output_format, **values).encode(
619 sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000620
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000621
622 def filter_issue(self, issue, should_filter_by_user=True):
623 def maybe_filter_username(email):
624 return not should_filter_by_user or username(email) == self.user
625 if (maybe_filter_username(issue['author']) and
626 self.filter_modified(issue['created'])):
627 return True
628 if (maybe_filter_username(issue['owner']) and
629 (self.filter_modified(issue['created']) or
630 self.filter_modified(issue['modified']))):
631 return True
632 for reply in issue['replies']:
633 if self.filter_modified(reply['created']):
634 if not should_filter_by_user:
635 break
636 if (username(reply['author']) == self.user
637 or (self.user + '@') in reply['content']):
638 break
639 else:
640 return False
641 return True
642
643 def filter_modified(self, modified):
644 return self.modified_after < modified and modified < self.modified_before
645
646 def auth_for_changes(self):
647 #TODO(cjhopman): Move authentication check for getting changes here.
648 pass
649
650 def auth_for_reviews(self):
651 # Reviews use all the same instances as changes so no authentication is
652 # required.
653 pass
654
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000655 def get_changes(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100656 num_instances = len(rietveld_instances) + len(gerrit_instances)
657 with contextlib.closing(ThreadPool(num_instances)) as pool:
658 rietveld_changes = pool.map_async(
659 lambda instance: self.rietveld_search(instance, owner=self.user),
660 rietveld_instances)
661 gerrit_changes = pool.map_async(
662 lambda instance: self.gerrit_search(instance, owner=self.user),
663 gerrit_instances)
664 rietveld_changes = itertools.chain.from_iterable(rietveld_changes.get())
665 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
666 self.changes = list(rietveld_changes) + list(gerrit_changes)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000667
668 def print_changes(self):
669 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000670 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000671 for change in self.changes:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100672 self.print_change(change)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000673
Vadim Bendebury8de38002018-05-14 19:02:55 -0700674 def print_access_errors(self):
675 if self.access_errors:
Ryan Harrison398fb442018-05-22 12:05:26 -0400676 logging.error('Access Errors:')
677 for error in self.access_errors:
678 logging.error(error.rstrip())
Vadim Bendebury8de38002018-05-14 19:02:55 -0700679
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000680 def get_reviews(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100681 num_instances = len(rietveld_instances) + len(gerrit_instances)
682 with contextlib.closing(ThreadPool(num_instances)) as pool:
683 rietveld_reviews = pool.map_async(
684 lambda instance: self.rietveld_search(instance, reviewer=self.user),
685 rietveld_instances)
686 gerrit_reviews = pool.map_async(
687 lambda instance: self.gerrit_search(instance, reviewer=self.user),
688 gerrit_instances)
689 rietveld_reviews = itertools.chain.from_iterable(rietveld_reviews.get())
690 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
jdoerrie356c2882018-07-23 10:02:02 +0000691 gerrit_reviews = [r for r in gerrit_reviews if not self.match(r['owner'])]
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100692 self.reviews = list(rietveld_reviews) + list(gerrit_reviews)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000693
694 def print_reviews(self):
695 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000696 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000697 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000698 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000699
700 def get_issues(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100701 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
702 monorail_issues = pool.map(
703 self.monorail_issue_search, monorail_projects.keys())
704 monorail_issues = list(itertools.chain.from_iterable(monorail_issues))
705
Vadim Bendeburycbf02042018-05-14 17:46:15 -0700706 if not monorail_issues:
707 return
708
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100709 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
710 filtered_issues = pool.map(
711 self.filter_modified_monorail_issue, monorail_issues)
712 self.issues = [issue for issue in filtered_issues if issue]
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100713
714 def get_referenced_issues(self):
715 if not self.issues:
716 self.get_issues()
717
718 if not self.changes:
719 self.get_changes()
720
721 referenced_issue_uids = set(itertools.chain.from_iterable(
722 change['bugs'] for change in self.changes))
723 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
724 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
725
726 missing_issues_by_project = collections.defaultdict(list)
727 for issue_uid in missing_issue_uids:
728 project, issue_id = issue_uid.split(':')
729 missing_issues_by_project[project].append(issue_id)
730
731 for project, issue_ids in missing_issues_by_project.iteritems():
732 self.referenced_issues += self.monorail_get_issues(project, issue_ids)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000733
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000734 def print_issues(self):
735 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000736 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000737 for issue in self.issues:
738 self.print_issue(issue)
739
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100740 def print_changes_by_issue(self, skip_empty_own):
741 if not self.issues or not self.changes:
742 return
743
744 self.print_heading('Changes by referenced issue(s)')
745 issues = {issue['uid']: issue for issue in self.issues}
746 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
747 changes_by_issue_uid = collections.defaultdict(list)
748 changes_by_ref_issue_uid = collections.defaultdict(list)
749 changes_without_issue = []
750 for change in self.changes:
751 added = False
Andrii Shyshkalov483580b2018-07-02 06:55:10 +0000752 for issue_uid in change['bugs']:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100753 if issue_uid in issues:
754 changes_by_issue_uid[issue_uid].append(change)
755 added = True
756 if issue_uid in ref_issues:
757 changes_by_ref_issue_uid[issue_uid].append(change)
758 added = True
759 if not added:
760 changes_without_issue.append(change)
761
762 # Changes referencing own issues.
763 for issue_uid in issues:
764 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
765 self.print_issue(issues[issue_uid])
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +0000766 if changes_by_issue_uid[issue_uid]:
767 print
768 for change in changes_by_issue_uid[issue_uid]:
769 print ' ', # this prints one space due to comma, but no newline
770 self.print_change(change)
771 print
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100772
773 # Changes referencing others' issues.
774 for issue_uid in ref_issues:
775 assert changes_by_ref_issue_uid[issue_uid]
776 self.print_issue(ref_issues[issue_uid])
777 for change in changes_by_ref_issue_uid[issue_uid]:
778 print '', # this prints one space due to comma, but no newline
779 self.print_change(change)
780
781 # Changes referencing no issues.
782 if changes_without_issue:
783 print self.options.output_format_no_url.format(title='Other changes')
784 for change in changes_without_issue:
785 print '', # this prints one space due to comma, but no newline
786 self.print_change(change)
787
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000788 def print_activity(self):
789 self.print_changes()
790 self.print_reviews()
791 self.print_issues()
792
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000793 def dump_json(self, ignore_keys=None):
794 if ignore_keys is None:
795 ignore_keys = ['replies']
796
797 def format_for_json_dump(in_array):
798 output = {}
799 for item in in_array:
800 url = item.get('url') or item.get('review_url')
801 if not url:
802 raise Exception('Dumped item %s does not specify url' % item)
803 output[url] = dict(
804 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
805 return output
806
807 class PythonObjectEncoder(json.JSONEncoder):
808 def default(self, obj): # pylint: disable=method-hidden
809 if isinstance(obj, datetime):
810 return obj.isoformat()
811 if isinstance(obj, set):
812 return list(obj)
813 return json.JSONEncoder.default(self, obj)
814
815 output = {
816 'reviews': format_for_json_dump(self.reviews),
817 'changes': format_for_json_dump(self.changes),
818 'issues': format_for_json_dump(self.issues)
819 }
820 print json.dumps(output, indent=2, cls=PythonObjectEncoder)
821
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000822
823def main():
824 # Silence upload.py.
825 rietveld.upload.verbosity = 0
826
827 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
828 parser.add_option(
829 '-u', '--user', metavar='<email>',
830 default=os.environ.get('USER'),
831 help='Filter on user, default=%default')
832 parser.add_option(
833 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000834 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000835 parser.add_option(
836 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000837 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000838 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
839 relativedelta(months=2))
840 parser.add_option(
841 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000842 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000843 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
844 parser.add_option(
845 '-Y', '--this_year', action='store_true',
846 help='Use this year\'s dates')
847 parser.add_option(
848 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000849 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000850 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000851 '-W', '--last_week', action='count',
852 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000853 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000854 '-a', '--auth',
855 action='store_true',
856 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000857 parser.add_option(
858 '-d', '--deltas',
859 action='store_true',
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800860 help='Fetch deltas for changes.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100861 parser.add_option(
862 '--no-referenced-issues',
863 action='store_true',
864 help='Do not fetch issues referenced by owned changes. Useful in '
865 'combination with --changes-by-issue when you only want to list '
Sergiy Byelozyorovb4475ab2018-03-23 17:49:34 +0100866 'issues that have also been modified in the same time period.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100867 parser.add_option(
868 '--skip-own-issues-without-changes',
869 action='store_true',
870 help='Skips listing own issues without changes when showing changes '
871 'grouped by referenced issue(s). See --changes-by-issue for more '
872 'details.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000873
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000874 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000875 'By default, all activity will be looked up and '
876 'printed. If any of these are specified, only '
877 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000878 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000879 '-c', '--changes',
880 action='store_true',
881 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000882 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000883 '-i', '--issues',
884 action='store_true',
885 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000886 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000887 '-r', '--reviews',
888 action='store_true',
889 help='Show reviews.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100890 activity_types_group.add_option(
891 '--changes-by-issue', action='store_true',
892 help='Show changes grouped by referenced issue(s).')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000893 parser.add_option_group(activity_types_group)
894
895 output_format_group = optparse.OptionGroup(parser, 'Output Format',
896 'By default, all activity will be printed in the '
897 'following format: {url} {title}. This can be '
898 'changed for either all activity types or '
899 'individually for each activity type. The format '
900 'is defined as documented for '
901 'string.format(...). The variables available for '
902 'all activity types are url, title and author. '
903 'Format options for specific activity types will '
904 'override the generic format.')
905 output_format_group.add_option(
906 '-f', '--output-format', metavar='<format>',
907 default=u'{url} {title}',
908 help='Specifies the format to use when printing all your activity.')
909 output_format_group.add_option(
910 '--output-format-changes', metavar='<format>',
911 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000912 help='Specifies the format to use when printing changes. Supports the '
913 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000914 output_format_group.add_option(
915 '--output-format-issues', metavar='<format>',
916 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000917 help='Specifies the format to use when printing issues. Supports the '
918 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000919 output_format_group.add_option(
920 '--output-format-reviews', metavar='<format>',
921 default=None,
922 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000923 output_format_group.add_option(
924 '--output-format-heading', metavar='<format>',
925 default=u'{heading}:',
926 help='Specifies the format to use when printing headings.')
927 output_format_group.add_option(
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100928 '--output-format-no-url', default='{title}',
929 help='Specifies the format to use when printing activity without url.')
930 output_format_group.add_option(
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000931 '-m', '--markdown', action='store_true',
932 help='Use markdown-friendly output (overrides --output-format '
933 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000934 output_format_group.add_option(
935 '-j', '--json', action='store_true',
936 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000937 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000938 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000939
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000940 parser.add_option(
941 '-v', '--verbose',
942 action='store_const',
943 dest='verbosity',
944 default=logging.WARN,
945 const=logging.INFO,
946 help='Output extra informational messages.'
947 )
948 parser.add_option(
949 '-q', '--quiet',
950 action='store_const',
951 dest='verbosity',
952 const=logging.ERROR,
953 help='Suppress non-error messages.'
954 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000955 parser.add_option(
956 '-o', '--output', metavar='<file>',
957 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000958
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000959 # Remove description formatting
960 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -0800961 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000962
963 options, args = parser.parse_args()
964 options.local_user = os.environ.get('USER')
965 if args:
966 parser.error('Args unsupported')
967 if not options.user:
968 parser.error('USER is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000969 options.user = username(options.user)
970
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000971 logging.basicConfig(level=options.verbosity)
972
973 # python-keyring provides easy access to the system keyring.
974 try:
975 import keyring # pylint: disable=unused-import,unused-variable,F0401
976 except ImportError:
977 logging.warning('Consider installing python-keyring')
978
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000979 if not options.begin:
980 if options.last_quarter:
981 begin, end = quarter_begin, quarter_end
982 elif options.this_year:
983 begin, end = get_year_of(datetime.today())
984 elif options.week_of:
985 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000986 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000987 begin, end = (get_week_of(datetime.today() -
988 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000989 else:
990 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
991 else:
Daniel Cheng4b37ce62017-09-07 12:00:02 -0700992 begin = dateutil.parser.parse(options.begin)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000993 if options.end:
Daniel Cheng4b37ce62017-09-07 12:00:02 -0700994 end = dateutil.parser.parse(options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000995 else:
996 end = datetime.today()
997 options.begin, options.end = begin, end
998
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000999 if options.markdown:
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +00001000 options.output_format_heading = '### {heading}\n'
1001 options.output_format = ' * [{title}]({url})'
1002 options.output_format_no_url = ' * {title}'
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001003 logging.info('Searching for activity by %s', options.user)
1004 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001005
1006 my_activity = MyActivity(options)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001007 my_activity.show_progress('Loading data')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001008
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001009 if not (options.changes or options.reviews or options.issues or
1010 options.changes_by_issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001011 options.changes = True
1012 options.issues = True
1013 options.reviews = True
1014
1015 # First do any required authentication so none of the user interaction has to
1016 # wait for actual work.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001017 if options.changes or options.changes_by_issue:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001018 my_activity.auth_for_changes()
1019 if options.reviews:
1020 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001021
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001022 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001023
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001024 try:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001025 if options.changes or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001026 my_activity.get_changes()
1027 if options.reviews:
1028 my_activity.get_reviews()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001029 if options.issues or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001030 my_activity.get_issues()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001031 if not options.no_referenced_issues:
1032 my_activity.get_referenced_issues()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001033 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001034 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001035
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001036 my_activity.show_progress('\n')
1037
Vadim Bendebury8de38002018-05-14 19:02:55 -07001038 my_activity.print_access_errors()
1039
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001040 output_file = None
1041 try:
1042 if options.output:
1043 output_file = open(options.output, 'w')
1044 logging.info('Printing output to "%s"', options.output)
1045 sys.stdout = output_file
1046 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -07001047 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001048 else:
1049 if options.json:
1050 my_activity.dump_json()
1051 else:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001052 if options.changes:
1053 my_activity.print_changes()
1054 if options.reviews:
1055 my_activity.print_reviews()
1056 if options.issues:
1057 my_activity.print_issues()
1058 if options.changes_by_issue:
1059 my_activity.print_changes_by_issue(
1060 options.skip_own_issues_without_changes)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001061 finally:
1062 if output_file:
1063 logging.info('Done printing to file.')
1064 sys.stdout = sys.__stdout__
1065 output_file.close()
1066
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001067 return 0
1068
1069
1070if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001071 # Fix encoding to support non-ascii issue titles.
1072 fix_encoding.fix_encoding()
1073
sbc@chromium.org013731e2015-02-26 18:28:43 +00001074 try:
1075 sys.exit(main())
1076 except KeyboardInterrupt:
1077 sys.stderr.write('interrupted\n')
1078 sys.exit(1)