blob: a18d6c7f874b0e9cf5afd49575bcf424591fef95 [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
199
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100200 def show_progress(self, how='.'):
201 if sys.stdout.isatty():
202 sys.stdout.write(how)
203 sys.stdout.flush()
204
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000205 # Check the codereview cookie jar to determine which Rietveld instances to
206 # authenticate to.
207 def check_cookies(self):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000208 filtered_instances = []
209
210 def has_cookie(instance):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000211 auth_config = auth.extract_auth_config_from_options(self.options)
212 a = auth.get_authenticator_for_host(instance['url'], auth_config)
213 return a.has_cached_credentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000214
215 for instance in rietveld_instances:
216 instance['auth'] = has_cookie(instance)
217
218 if filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000219 logging.warning('No cookie found for the following Rietveld instance%s:',
220 's' if len(filtered_instances) > 1 else '')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000221 for instance in filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000222 logging.warning('\t' + instance['url'])
223 logging.warning('Use --auth if you would like to authenticate to them.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000224
225 def rietveld_search(self, instance, owner=None, reviewer=None):
226 if instance['requires_auth'] and not instance['auth']:
227 return []
228
229
230 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000231 auth_config = auth.extract_auth_config_from_options(self.options)
232 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000233
234 # See def search() in rietveld.py to see all the filters you can use.
235 query_modified_after = None
236
237 if instance['supports_owner_modified_query']:
238 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
239
240 # Rietveld does not allow search by both created_before and modified_after.
241 # (And some instances don't allow search by both owner and modified_after)
242 owner_email = None
243 reviewer_email = None
244 if owner:
245 owner_email = owner + '@' + instance['email_domain']
246 if reviewer:
247 reviewer_email = reviewer + '@' + instance['email_domain']
248 issues = remote.search(
249 owner=owner_email,
250 reviewer=reviewer_email,
251 modified_after=query_modified_after,
252 with_messages=True)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100253 self.show_progress()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000254
255 issues = filter(
256 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
257 issues)
258 issues = filter(
259 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
260 issues)
261
262 should_filter_by_user = True
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000263 issues = map(partial(self.process_rietveld_issue, remote, instance), issues)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000264 issues = filter(
265 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
266 issues)
267 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
268
269 return issues
270
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000271 def extract_bug_number_from_description(self, issue):
272 description = None
273
274 if 'description' in issue:
275 # Getting the description for Rietveld
276 description = issue['description']
277 elif 'revisions' in issue:
278 # Getting the description for REST Gerrit
279 revision = issue['revisions'][issue['current_revision']]
280 description = revision['commit']['message']
281
282 bugs = []
283 if description:
Nicolas Dossou-gbete903ea732017-07-10 16:46:59 +0100284 # Handle both "Bug: 99999" and "BUG=99999" bug notations
285 # Multiple bugs can be noted on a single line or in multiple ones.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100286 matches = re.findall(
287 r'BUG[=:]\s?((((?:[a-zA-Z0-9-]+:)?\d+)(,\s?)?)+)', description,
288 flags=re.IGNORECASE)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000289 if matches:
290 for match in matches:
291 bugs.extend(match[0].replace(' ', '').split(','))
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100292 # Add default chromium: prefix if none specified.
293 bugs = [bug if ':' in bug else 'chromium:%s' % bug for bug in bugs]
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000294
295 return bugs
296
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000297 def process_rietveld_issue(self, remote, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000298 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000299 if self.options.deltas:
300 patchset_props = remote.get_patchset_properties(
301 issue['issue'],
302 issue['patchsets'][-1])
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100303 self.show_progress()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000304 ret['delta'] = '+%d,-%d' % (
305 sum(f['num_added'] for f in patchset_props['files'].itervalues()),
306 sum(f['num_removed'] for f in patchset_props['files'].itervalues()))
307
308 if issue['landed_days_ago'] != 'unknown':
309 ret['status'] = 'committed'
310 elif issue['closed']:
311 ret['status'] = 'closed'
312 elif len(issue['reviewers']) and issue['all_required_reviewers_approved']:
313 ret['status'] = 'ready'
314 else:
315 ret['status'] = 'open'
316
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000317 ret['owner'] = issue['owner_email']
318 ret['author'] = ret['owner']
319
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000320 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000321
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000322 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700323 url = instance['shorturl']
324 protocol = instance.get('short_url_protocol', 'http')
325 else:
326 url = instance['url']
327 protocol = 'https'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000328
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700329 ret['review_url'] = '%s://%s/%d' % (protocol, url, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000330
331 # Rietveld sometimes has '\r\n' instead of '\n'.
332 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000333
334 ret['modified'] = datetime_from_rietveld(issue['modified'])
335 ret['created'] = datetime_from_rietveld(issue['created'])
336 ret['replies'] = self.process_rietveld_replies(issue['messages'])
337
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100338 ret['bugs'] = self.extract_bug_number_from_description(issue)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000339 ret['landed_days_ago'] = issue['landed_days_ago']
340
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000341 return ret
342
343 @staticmethod
344 def process_rietveld_replies(replies):
345 ret = []
346 for reply in replies:
347 r = {}
348 r['author'] = reply['sender']
349 r['created'] = datetime_from_rietveld(reply['date'])
350 r['content'] = ''
351 ret.append(r)
352 return ret
353
deymo@chromium.org6c039202013-09-12 12:28:12 +0000354 @staticmethod
deymo@chromium.org6c039202013-09-12 12:28:12 +0000355 def gerrit_changes_over_rest(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:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000365 logging.error('Looking up %r: %s', instance['url'], e)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000366 return []
367
deymo@chromium.org6c039202013-09-12 12:28:12 +0000368 def gerrit_search(self, instance, owner=None, reviewer=None):
369 max_age = datetime.today() - self.modified_after
370 max_age = max_age.days * 24 * 3600 + max_age.seconds
371 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
372 filters = ['-age:%ss' % max_age, user_filter]
373
Aaron Gable2979a872017-09-05 17:38:32 -0700374 issues = self.gerrit_changes_over_rest(instance, filters)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100375 self.show_progress()
Aaron Gable2979a872017-09-05 17:38:32 -0700376 issues = [self.process_gerrit_issue(instance, issue)
377 for issue in issues]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000378
379 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000380 issues = filter(self.filter_issue, issues)
381 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
382
383 return issues
384
Aaron Gable2979a872017-09-05 17:38:32 -0700385 def process_gerrit_issue(self, instance, issue):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000386 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000387 if self.options.deltas:
388 ret['delta'] = DefaultFormatter().format(
389 '+{insertions},-{deletions}',
390 **issue)
391 ret['status'] = issue['status']
deymo@chromium.org6c039202013-09-12 12:28:12 +0000392 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700393 protocol = instance.get('short_url_protocol', 'http')
394 url = instance['shorturl']
395 else:
396 protocol = 'https'
397 url = instance['url']
398 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
399
deymo@chromium.org6c039202013-09-12 12:28:12 +0000400 ret['header'] = issue['subject']
401 ret['owner'] = issue['owner']['email']
402 ret['author'] = ret['owner']
403 ret['created'] = datetime_from_gerrit(issue['created'])
404 ret['modified'] = datetime_from_gerrit(issue['updated'])
405 if 'messages' in issue:
Aaron Gable2979a872017-09-05 17:38:32 -0700406 ret['replies'] = self.process_gerrit_issue_replies(issue['messages'])
deymo@chromium.org6c039202013-09-12 12:28:12 +0000407 else:
408 ret['replies'] = []
409 ret['reviewers'] = set(r['author'] for r in ret['replies'])
410 ret['reviewers'].discard(ret['author'])
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100411 ret['bugs'] = self.extract_bug_number_from_description(issue)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000412 return ret
413
414 @staticmethod
Aaron Gable2979a872017-09-05 17:38:32 -0700415 def process_gerrit_issue_replies(replies):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000416 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000417 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
418 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000419 for reply in replies:
420 ret.append({
421 'author': reply['author']['email'],
422 'created': datetime_from_gerrit(reply['date']),
423 'content': reply['message'],
424 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000425 return ret
426
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100427 def monorail_get_auth_http(self):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000428 auth_config = auth.extract_auth_config_from_options(self.options)
429 authenticator = auth.get_authenticator_for_host(
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000430 'bugs.chromium.org', auth_config)
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100431 return authenticator.authorize(httplib2.Http())
432
433 def filter_modified_monorail_issue(self, issue):
434 """Precisely checks if an issue has been modified in the time range.
435
436 This fetches all issue comments to check if the issue has been modified in
437 the time range specified by user. This is needed because monorail only
438 allows filtering by last updated and published dates, which is not
439 sufficient to tell whether a given issue has been modified at some specific
440 time range. Any update to the issue is a reported as comment on Monorail.
441
442 Args:
443 issue: Issue dict as returned by monorail_query_issues method. In
444 particular, must have a key 'uid' formatted as 'project:issue_id'.
445
446 Returns:
447 Passed issue if modified, None otherwise.
448 """
449 http = self.monorail_get_auth_http()
450 project, issue_id = issue['uid'].split(':')
451 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
452 '/%s/issues/%s/comments?maxResults=10000') % (project, issue_id)
453 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100454 self.show_progress()
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100455 content = json.loads(body)
456 if not content:
457 logging.error('Unable to parse %s response from monorail.', project)
458 return issue
459
460 for item in content.get('items', []):
461 comment_published = datetime_from_monorail(item['published'])
462 if self.filter_modified(comment_published):
463 return issue
464
465 return None
466
467 def monorail_query_issues(self, project, query):
468 http = self.monorail_get_auth_http()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000469 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100470 '/%s/issues') % project
471 query_data = urllib.urlencode(query)
472 url = url + '?' + query_data
473 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100474 self.show_progress()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100475 content = json.loads(body)
476 if not content:
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100477 logging.error('Unable to parse %s response from monorail.', project)
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100478 return []
479
480 issues = []
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100481 project_config = monorail_projects.get(project, {})
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100482 for item in content.get('items', []):
483 if project_config.get('shorturl'):
484 protocol = project_config.get('short_url_protocol', 'http')
485 item_url = '%s://%s/%d' % (
486 protocol, project_config['shorturl'], item['id'])
487 else:
488 item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
489 project, item['id'])
490 issue = {
491 'uid': '%s:%s' % (project, item['id']),
492 'header': item['title'],
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100493 'created': datetime_from_monorail(item['published']),
494 'modified': datetime_from_monorail(item['updated']),
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100495 'author': item['author']['name'],
496 'url': item_url,
497 'comments': [],
498 'status': item['status'],
499 'labels': [],
500 'components': []
501 }
502 if 'owner' in item:
503 issue['owner'] = item['owner']['name']
504 else:
505 issue['owner'] = 'None'
506 if 'labels' in item:
507 issue['labels'] = item['labels']
508 if 'components' in item:
509 issue['components'] = item['components']
510 issues.append(issue)
511
512 return issues
513
514 def monorail_issue_search(self, project):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000515 epoch = datetime.utcfromtimestamp(0)
516 user_str = '%s@chromium.org' % self.user
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000517
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100518 issues = self.monorail_query_issues(project, {
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000519 'maxResults': 10000,
520 'q': user_str,
521 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
522 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000523 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000524
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100525 return [
526 issue for issue in issues
527 if issue['author'] == user_str or issue['owner'] == user_str]
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000528
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100529 def monorail_get_issues(self, project, issue_ids):
530 return self.monorail_query_issues(project, {
531 'maxResults': 10000,
532 'q': 'id:%s' % ','.join(issue_ids)
533 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000534
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000535 def print_heading(self, heading):
536 print
537 print self.options.output_format_heading.format(heading=heading)
538
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000539 def match(self, author):
540 if '@' in self.user:
541 return author == self.user
542 return author.startswith(self.user + '@')
543
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000544 def print_change(self, change):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000545 activity = len([
546 reply
547 for reply in change['replies']
548 if self.match(reply['author'])
549 ])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000550 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000551 'created': change['created'].date().isoformat(),
552 'modified': change['modified'].date().isoformat(),
553 'reviewers': ', '.join(change['reviewers']),
554 'status': change['status'],
555 'activity': activity,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000556 }
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000557 if self.options.deltas:
558 optional_values['delta'] = change['delta']
559
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000560 self.print_generic(self.options.output_format,
561 self.options.output_format_changes,
562 change['header'],
563 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000564 change['author'],
565 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000566
567 def print_issue(self, issue):
568 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000569 'created': issue['created'].date().isoformat(),
570 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000571 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000572 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000573 }
574 self.print_generic(self.options.output_format,
575 self.options.output_format_issues,
576 issue['header'],
577 issue['url'],
578 issue['author'],
579 optional_values)
580
581 def print_review(self, review):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000582 activity = len([
583 reply
584 for reply in review['replies']
585 if self.match(reply['author'])
586 ])
587 optional_values = {
588 'created': review['created'].date().isoformat(),
589 'modified': review['modified'].date().isoformat(),
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800590 'status': review['status'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000591 'activity': activity,
592 }
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800593 if self.options.deltas:
594 optional_values['delta'] = review['delta']
595
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000596 self.print_generic(self.options.output_format,
597 self.options.output_format_reviews,
598 review['header'],
599 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000600 review['author'],
601 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000602
603 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000604 def print_generic(default_fmt, specific_fmt,
605 title, url, author,
606 optional_values=None):
607 output_format = specific_fmt if specific_fmt is not None else default_fmt
608 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000609 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000610 'title': title,
611 'url': url,
612 'author': author,
613 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000614 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000615 values.update(optional_values)
616 print DefaultFormatter().format(output_format, **values).encode(
617 sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000618
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000619
620 def filter_issue(self, issue, should_filter_by_user=True):
621 def maybe_filter_username(email):
622 return not should_filter_by_user or username(email) == self.user
623 if (maybe_filter_username(issue['author']) and
624 self.filter_modified(issue['created'])):
625 return True
626 if (maybe_filter_username(issue['owner']) and
627 (self.filter_modified(issue['created']) or
628 self.filter_modified(issue['modified']))):
629 return True
630 for reply in issue['replies']:
631 if self.filter_modified(reply['created']):
632 if not should_filter_by_user:
633 break
634 if (username(reply['author']) == self.user
635 or (self.user + '@') in reply['content']):
636 break
637 else:
638 return False
639 return True
640
641 def filter_modified(self, modified):
642 return self.modified_after < modified and modified < self.modified_before
643
644 def auth_for_changes(self):
645 #TODO(cjhopman): Move authentication check for getting changes here.
646 pass
647
648 def auth_for_reviews(self):
649 # Reviews use all the same instances as changes so no authentication is
650 # required.
651 pass
652
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000653 def get_changes(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100654 num_instances = len(rietveld_instances) + len(gerrit_instances)
655 with contextlib.closing(ThreadPool(num_instances)) as pool:
656 rietveld_changes = pool.map_async(
657 lambda instance: self.rietveld_search(instance, owner=self.user),
658 rietveld_instances)
659 gerrit_changes = pool.map_async(
660 lambda instance: self.gerrit_search(instance, owner=self.user),
661 gerrit_instances)
662 rietveld_changes = itertools.chain.from_iterable(rietveld_changes.get())
663 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
664 self.changes = list(rietveld_changes) + list(gerrit_changes)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000665
666 def print_changes(self):
667 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000668 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000669 for change in self.changes:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100670 self.print_change(change)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000671
672 def get_reviews(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100673 num_instances = len(rietveld_instances) + len(gerrit_instances)
674 with contextlib.closing(ThreadPool(num_instances)) as pool:
675 rietveld_reviews = pool.map_async(
676 lambda instance: self.rietveld_search(instance, reviewer=self.user),
677 rietveld_instances)
678 gerrit_reviews = pool.map_async(
679 lambda instance: self.gerrit_search(instance, reviewer=self.user),
680 gerrit_instances)
681 rietveld_reviews = itertools.chain.from_iterable(rietveld_reviews.get())
682 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
683 gerrit_reviews = [r for r in gerrit_reviews if r['owner'] != self.user]
684 self.reviews = list(rietveld_reviews) + list(gerrit_reviews)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000685
686 def print_reviews(self):
687 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000688 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000689 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000690 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000691
692 def get_issues(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100693 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
694 monorail_issues = pool.map(
695 self.monorail_issue_search, monorail_projects.keys())
696 monorail_issues = list(itertools.chain.from_iterable(monorail_issues))
697
698 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
699 filtered_issues = pool.map(
700 self.filter_modified_monorail_issue, monorail_issues)
701 self.issues = [issue for issue in filtered_issues if issue]
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100702
703 def get_referenced_issues(self):
704 if not self.issues:
705 self.get_issues()
706
707 if not self.changes:
708 self.get_changes()
709
710 referenced_issue_uids = set(itertools.chain.from_iterable(
711 change['bugs'] for change in self.changes))
712 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
713 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
714
715 missing_issues_by_project = collections.defaultdict(list)
716 for issue_uid in missing_issue_uids:
717 project, issue_id = issue_uid.split(':')
718 missing_issues_by_project[project].append(issue_id)
719
720 for project, issue_ids in missing_issues_by_project.iteritems():
721 self.referenced_issues += self.monorail_get_issues(project, issue_ids)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000722
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000723 def print_issues(self):
724 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000725 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000726 for issue in self.issues:
727 self.print_issue(issue)
728
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100729 def print_changes_by_issue(self, skip_empty_own):
730 if not self.issues or not self.changes:
731 return
732
733 self.print_heading('Changes by referenced issue(s)')
734 issues = {issue['uid']: issue for issue in self.issues}
735 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
736 changes_by_issue_uid = collections.defaultdict(list)
737 changes_by_ref_issue_uid = collections.defaultdict(list)
738 changes_without_issue = []
739 for change in self.changes:
740 added = False
741 for issue_uid in change['bugs']:
742 if issue_uid in issues:
743 changes_by_issue_uid[issue_uid].append(change)
744 added = True
745 if issue_uid in ref_issues:
746 changes_by_ref_issue_uid[issue_uid].append(change)
747 added = True
748 if not added:
749 changes_without_issue.append(change)
750
751 # Changes referencing own issues.
752 for issue_uid in issues:
753 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
754 self.print_issue(issues[issue_uid])
755 for change in changes_by_issue_uid[issue_uid]:
756 print '', # this prints one space due to comma, but no newline
757 self.print_change(change)
758
759 # Changes referencing others' issues.
760 for issue_uid in ref_issues:
761 assert changes_by_ref_issue_uid[issue_uid]
762 self.print_issue(ref_issues[issue_uid])
763 for change in changes_by_ref_issue_uid[issue_uid]:
764 print '', # this prints one space due to comma, but no newline
765 self.print_change(change)
766
767 # Changes referencing no issues.
768 if changes_without_issue:
769 print self.options.output_format_no_url.format(title='Other changes')
770 for change in changes_without_issue:
771 print '', # this prints one space due to comma, but no newline
772 self.print_change(change)
773
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000774 def print_activity(self):
775 self.print_changes()
776 self.print_reviews()
777 self.print_issues()
778
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000779 def dump_json(self, ignore_keys=None):
780 if ignore_keys is None:
781 ignore_keys = ['replies']
782
783 def format_for_json_dump(in_array):
784 output = {}
785 for item in in_array:
786 url = item.get('url') or item.get('review_url')
787 if not url:
788 raise Exception('Dumped item %s does not specify url' % item)
789 output[url] = dict(
790 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
791 return output
792
793 class PythonObjectEncoder(json.JSONEncoder):
794 def default(self, obj): # pylint: disable=method-hidden
795 if isinstance(obj, datetime):
796 return obj.isoformat()
797 if isinstance(obj, set):
798 return list(obj)
799 return json.JSONEncoder.default(self, obj)
800
801 output = {
802 'reviews': format_for_json_dump(self.reviews),
803 'changes': format_for_json_dump(self.changes),
804 'issues': format_for_json_dump(self.issues)
805 }
806 print json.dumps(output, indent=2, cls=PythonObjectEncoder)
807
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000808
809def main():
810 # Silence upload.py.
811 rietveld.upload.verbosity = 0
812
813 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
814 parser.add_option(
815 '-u', '--user', metavar='<email>',
816 default=os.environ.get('USER'),
817 help='Filter on user, default=%default')
818 parser.add_option(
819 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000820 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000821 parser.add_option(
822 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000823 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000824 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
825 relativedelta(months=2))
826 parser.add_option(
827 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000828 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000829 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
830 parser.add_option(
831 '-Y', '--this_year', action='store_true',
832 help='Use this year\'s dates')
833 parser.add_option(
834 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000835 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000836 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000837 '-W', '--last_week', action='count',
838 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000839 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000840 '-a', '--auth',
841 action='store_true',
842 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000843 parser.add_option(
844 '-d', '--deltas',
845 action='store_true',
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800846 help='Fetch deltas for changes.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100847 parser.add_option(
848 '--no-referenced-issues',
849 action='store_true',
850 help='Do not fetch issues referenced by owned changes. Useful in '
851 'combination with --changes-by-issue when you only want to list '
Sergiy Byelozyorovb4475ab2018-03-23 17:49:34 +0100852 'issues that have also been modified in the same time period.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100853 parser.add_option(
854 '--skip-own-issues-without-changes',
855 action='store_true',
856 help='Skips listing own issues without changes when showing changes '
857 'grouped by referenced issue(s). See --changes-by-issue for more '
858 'details.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000859
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000860 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000861 'By default, all activity will be looked up and '
862 'printed. If any of these are specified, only '
863 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000864 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000865 '-c', '--changes',
866 action='store_true',
867 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000868 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000869 '-i', '--issues',
870 action='store_true',
871 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000872 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000873 '-r', '--reviews',
874 action='store_true',
875 help='Show reviews.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100876 activity_types_group.add_option(
877 '--changes-by-issue', action='store_true',
878 help='Show changes grouped by referenced issue(s).')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000879 parser.add_option_group(activity_types_group)
880
881 output_format_group = optparse.OptionGroup(parser, 'Output Format',
882 'By default, all activity will be printed in the '
883 'following format: {url} {title}. This can be '
884 'changed for either all activity types or '
885 'individually for each activity type. The format '
886 'is defined as documented for '
887 'string.format(...). The variables available for '
888 'all activity types are url, title and author. '
889 'Format options for specific activity types will '
890 'override the generic format.')
891 output_format_group.add_option(
892 '-f', '--output-format', metavar='<format>',
893 default=u'{url} {title}',
894 help='Specifies the format to use when printing all your activity.')
895 output_format_group.add_option(
896 '--output-format-changes', metavar='<format>',
897 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000898 help='Specifies the format to use when printing changes. Supports the '
899 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000900 output_format_group.add_option(
901 '--output-format-issues', metavar='<format>',
902 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000903 help='Specifies the format to use when printing issues. Supports the '
904 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000905 output_format_group.add_option(
906 '--output-format-reviews', metavar='<format>',
907 default=None,
908 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000909 output_format_group.add_option(
910 '--output-format-heading', metavar='<format>',
911 default=u'{heading}:',
912 help='Specifies the format to use when printing headings.')
913 output_format_group.add_option(
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100914 '--output-format-no-url', default='{title}',
915 help='Specifies the format to use when printing activity without url.')
916 output_format_group.add_option(
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000917 '-m', '--markdown', action='store_true',
918 help='Use markdown-friendly output (overrides --output-format '
919 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000920 output_format_group.add_option(
921 '-j', '--json', action='store_true',
922 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000923 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000924 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000925
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000926 parser.add_option(
927 '-v', '--verbose',
928 action='store_const',
929 dest='verbosity',
930 default=logging.WARN,
931 const=logging.INFO,
932 help='Output extra informational messages.'
933 )
934 parser.add_option(
935 '-q', '--quiet',
936 action='store_const',
937 dest='verbosity',
938 const=logging.ERROR,
939 help='Suppress non-error messages.'
940 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000941 parser.add_option(
942 '-o', '--output', metavar='<file>',
943 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000944
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000945 # Remove description formatting
946 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -0800947 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000948
949 options, args = parser.parse_args()
950 options.local_user = os.environ.get('USER')
951 if args:
952 parser.error('Args unsupported')
953 if not options.user:
954 parser.error('USER is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000955 options.user = username(options.user)
956
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000957 logging.basicConfig(level=options.verbosity)
958
959 # python-keyring provides easy access to the system keyring.
960 try:
961 import keyring # pylint: disable=unused-import,unused-variable,F0401
962 except ImportError:
963 logging.warning('Consider installing python-keyring')
964
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000965 if not options.begin:
966 if options.last_quarter:
967 begin, end = quarter_begin, quarter_end
968 elif options.this_year:
969 begin, end = get_year_of(datetime.today())
970 elif options.week_of:
971 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000972 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000973 begin, end = (get_week_of(datetime.today() -
974 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000975 else:
976 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
977 else:
Daniel Cheng4b37ce62017-09-07 12:00:02 -0700978 begin = dateutil.parser.parse(options.begin)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000979 if options.end:
Daniel Cheng4b37ce62017-09-07 12:00:02 -0700980 end = dateutil.parser.parse(options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000981 else:
982 end = datetime.today()
983 options.begin, options.end = begin, end
984
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000985 if options.markdown:
986 options.output_format = ' * [{title}]({url})'
987 options.output_format_heading = '### {heading} ###'
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100988 options.output_format_no_url = ' * {title}'
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000989 logging.info('Searching for activity by %s', options.user)
990 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000991
992 my_activity = MyActivity(options)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100993 my_activity.show_progress('Loading data')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000994
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100995 if not (options.changes or options.reviews or options.issues or
996 options.changes_by_issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000997 options.changes = True
998 options.issues = True
999 options.reviews = True
1000
1001 # First do any required authentication so none of the user interaction has to
1002 # wait for actual work.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001003 if options.changes or options.changes_by_issue:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001004 my_activity.auth_for_changes()
1005 if options.reviews:
1006 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001007
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001008 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001009
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001010 try:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001011 if options.changes or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001012 my_activity.get_changes()
1013 if options.reviews:
1014 my_activity.get_reviews()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001015 if options.issues or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001016 my_activity.get_issues()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001017 if not options.no_referenced_issues:
1018 my_activity.get_referenced_issues()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001019 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001020 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001021
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001022 my_activity.show_progress('\n')
1023
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001024 output_file = None
1025 try:
1026 if options.output:
1027 output_file = open(options.output, 'w')
1028 logging.info('Printing output to "%s"', options.output)
1029 sys.stdout = output_file
1030 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -07001031 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001032 else:
1033 if options.json:
1034 my_activity.dump_json()
1035 else:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001036 if options.changes:
1037 my_activity.print_changes()
1038 if options.reviews:
1039 my_activity.print_reviews()
1040 if options.issues:
1041 my_activity.print_issues()
1042 if options.changes_by_issue:
1043 my_activity.print_changes_by_issue(
1044 options.skip_own_issues_without_changes)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001045 finally:
1046 if output_file:
1047 logging.info('Done printing to file.')
1048 sys.stdout = sys.__stdout__
1049 output_file.close()
1050
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001051 return 0
1052
1053
1054if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001055 # Fix encoding to support non-ascii issue titles.
1056 fix_encoding.fix_encoding()
1057
sbc@chromium.org013731e2015-02-26 18:28:43 +00001058 try:
1059 sys.exit(main())
1060 except KeyboardInterrupt:
1061 sys.stderr.write('interrupted\n')
1062 sys.exit(1)