blob: 17f4a13fac44e16e04c4f6bca48c819213a53c56 [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
Vadim Bendeburycbf02042018-05-14 17:46:15 -0700698 if not monorail_issues:
699 return
700
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100701 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
702 filtered_issues = pool.map(
703 self.filter_modified_monorail_issue, monorail_issues)
704 self.issues = [issue for issue in filtered_issues if issue]
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100705
706 def get_referenced_issues(self):
707 if not self.issues:
708 self.get_issues()
709
710 if not self.changes:
711 self.get_changes()
712
713 referenced_issue_uids = set(itertools.chain.from_iterable(
714 change['bugs'] for change in self.changes))
715 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
716 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
717
718 missing_issues_by_project = collections.defaultdict(list)
719 for issue_uid in missing_issue_uids:
720 project, issue_id = issue_uid.split(':')
721 missing_issues_by_project[project].append(issue_id)
722
723 for project, issue_ids in missing_issues_by_project.iteritems():
724 self.referenced_issues += self.monorail_get_issues(project, issue_ids)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000725
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000726 def print_issues(self):
727 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000728 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000729 for issue in self.issues:
730 self.print_issue(issue)
731
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100732 def print_changes_by_issue(self, skip_empty_own):
733 if not self.issues or not self.changes:
734 return
735
736 self.print_heading('Changes by referenced issue(s)')
737 issues = {issue['uid']: issue for issue in self.issues}
738 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
739 changes_by_issue_uid = collections.defaultdict(list)
740 changes_by_ref_issue_uid = collections.defaultdict(list)
741 changes_without_issue = []
742 for change in self.changes:
743 added = False
744 for issue_uid in change['bugs']:
745 if issue_uid in issues:
746 changes_by_issue_uid[issue_uid].append(change)
747 added = True
748 if issue_uid in ref_issues:
749 changes_by_ref_issue_uid[issue_uid].append(change)
750 added = True
751 if not added:
752 changes_without_issue.append(change)
753
754 # Changes referencing own issues.
755 for issue_uid in issues:
756 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
757 self.print_issue(issues[issue_uid])
758 for change in changes_by_issue_uid[issue_uid]:
759 print '', # this prints one space due to comma, but no newline
760 self.print_change(change)
761
762 # Changes referencing others' issues.
763 for issue_uid in ref_issues:
764 assert changes_by_ref_issue_uid[issue_uid]
765 self.print_issue(ref_issues[issue_uid])
766 for change in changes_by_ref_issue_uid[issue_uid]:
767 print '', # this prints one space due to comma, but no newline
768 self.print_change(change)
769
770 # Changes referencing no issues.
771 if changes_without_issue:
772 print self.options.output_format_no_url.format(title='Other changes')
773 for change in changes_without_issue:
774 print '', # this prints one space due to comma, but no newline
775 self.print_change(change)
776
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000777 def print_activity(self):
778 self.print_changes()
779 self.print_reviews()
780 self.print_issues()
781
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000782 def dump_json(self, ignore_keys=None):
783 if ignore_keys is None:
784 ignore_keys = ['replies']
785
786 def format_for_json_dump(in_array):
787 output = {}
788 for item in in_array:
789 url = item.get('url') or item.get('review_url')
790 if not url:
791 raise Exception('Dumped item %s does not specify url' % item)
792 output[url] = dict(
793 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
794 return output
795
796 class PythonObjectEncoder(json.JSONEncoder):
797 def default(self, obj): # pylint: disable=method-hidden
798 if isinstance(obj, datetime):
799 return obj.isoformat()
800 if isinstance(obj, set):
801 return list(obj)
802 return json.JSONEncoder.default(self, obj)
803
804 output = {
805 'reviews': format_for_json_dump(self.reviews),
806 'changes': format_for_json_dump(self.changes),
807 'issues': format_for_json_dump(self.issues)
808 }
809 print json.dumps(output, indent=2, cls=PythonObjectEncoder)
810
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000811
812def main():
813 # Silence upload.py.
814 rietveld.upload.verbosity = 0
815
816 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
817 parser.add_option(
818 '-u', '--user', metavar='<email>',
819 default=os.environ.get('USER'),
820 help='Filter on user, default=%default')
821 parser.add_option(
822 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000823 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000824 parser.add_option(
825 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000826 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000827 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
828 relativedelta(months=2))
829 parser.add_option(
830 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000831 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000832 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
833 parser.add_option(
834 '-Y', '--this_year', action='store_true',
835 help='Use this year\'s dates')
836 parser.add_option(
837 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000838 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000839 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000840 '-W', '--last_week', action='count',
841 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000842 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000843 '-a', '--auth',
844 action='store_true',
845 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000846 parser.add_option(
847 '-d', '--deltas',
848 action='store_true',
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800849 help='Fetch deltas for changes.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100850 parser.add_option(
851 '--no-referenced-issues',
852 action='store_true',
853 help='Do not fetch issues referenced by owned changes. Useful in '
854 'combination with --changes-by-issue when you only want to list '
Sergiy Byelozyorovb4475ab2018-03-23 17:49:34 +0100855 'issues that have also been modified in the same time period.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100856 parser.add_option(
857 '--skip-own-issues-without-changes',
858 action='store_true',
859 help='Skips listing own issues without changes when showing changes '
860 'grouped by referenced issue(s). See --changes-by-issue for more '
861 'details.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000862
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000863 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000864 'By default, all activity will be looked up and '
865 'printed. If any of these are specified, only '
866 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000867 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000868 '-c', '--changes',
869 action='store_true',
870 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000871 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000872 '-i', '--issues',
873 action='store_true',
874 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000875 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000876 '-r', '--reviews',
877 action='store_true',
878 help='Show reviews.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100879 activity_types_group.add_option(
880 '--changes-by-issue', action='store_true',
881 help='Show changes grouped by referenced issue(s).')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000882 parser.add_option_group(activity_types_group)
883
884 output_format_group = optparse.OptionGroup(parser, 'Output Format',
885 'By default, all activity will be printed in the '
886 'following format: {url} {title}. This can be '
887 'changed for either all activity types or '
888 'individually for each activity type. The format '
889 'is defined as documented for '
890 'string.format(...). The variables available for '
891 'all activity types are url, title and author. '
892 'Format options for specific activity types will '
893 'override the generic format.')
894 output_format_group.add_option(
895 '-f', '--output-format', metavar='<format>',
896 default=u'{url} {title}',
897 help='Specifies the format to use when printing all your activity.')
898 output_format_group.add_option(
899 '--output-format-changes', metavar='<format>',
900 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000901 help='Specifies the format to use when printing changes. Supports the '
902 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000903 output_format_group.add_option(
904 '--output-format-issues', metavar='<format>',
905 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000906 help='Specifies the format to use when printing issues. Supports the '
907 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000908 output_format_group.add_option(
909 '--output-format-reviews', metavar='<format>',
910 default=None,
911 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000912 output_format_group.add_option(
913 '--output-format-heading', metavar='<format>',
914 default=u'{heading}:',
915 help='Specifies the format to use when printing headings.')
916 output_format_group.add_option(
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100917 '--output-format-no-url', default='{title}',
918 help='Specifies the format to use when printing activity without url.')
919 output_format_group.add_option(
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000920 '-m', '--markdown', action='store_true',
921 help='Use markdown-friendly output (overrides --output-format '
922 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000923 output_format_group.add_option(
924 '-j', '--json', action='store_true',
925 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000926 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000927 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000928
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000929 parser.add_option(
930 '-v', '--verbose',
931 action='store_const',
932 dest='verbosity',
933 default=logging.WARN,
934 const=logging.INFO,
935 help='Output extra informational messages.'
936 )
937 parser.add_option(
938 '-q', '--quiet',
939 action='store_const',
940 dest='verbosity',
941 const=logging.ERROR,
942 help='Suppress non-error messages.'
943 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000944 parser.add_option(
945 '-o', '--output', metavar='<file>',
946 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000947
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000948 # Remove description formatting
949 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -0800950 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000951
952 options, args = parser.parse_args()
953 options.local_user = os.environ.get('USER')
954 if args:
955 parser.error('Args unsupported')
956 if not options.user:
957 parser.error('USER is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000958 options.user = username(options.user)
959
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000960 logging.basicConfig(level=options.verbosity)
961
962 # python-keyring provides easy access to the system keyring.
963 try:
964 import keyring # pylint: disable=unused-import,unused-variable,F0401
965 except ImportError:
966 logging.warning('Consider installing python-keyring')
967
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000968 if not options.begin:
969 if options.last_quarter:
970 begin, end = quarter_begin, quarter_end
971 elif options.this_year:
972 begin, end = get_year_of(datetime.today())
973 elif options.week_of:
974 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000975 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000976 begin, end = (get_week_of(datetime.today() -
977 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000978 else:
979 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
980 else:
Daniel Cheng4b37ce62017-09-07 12:00:02 -0700981 begin = dateutil.parser.parse(options.begin)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000982 if options.end:
Daniel Cheng4b37ce62017-09-07 12:00:02 -0700983 end = dateutil.parser.parse(options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000984 else:
985 end = datetime.today()
986 options.begin, options.end = begin, end
987
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000988 if options.markdown:
989 options.output_format = ' * [{title}]({url})'
990 options.output_format_heading = '### {heading} ###'
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100991 options.output_format_no_url = ' * {title}'
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000992 logging.info('Searching for activity by %s', options.user)
993 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000994
995 my_activity = MyActivity(options)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100996 my_activity.show_progress('Loading data')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000997
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100998 if not (options.changes or options.reviews or options.issues or
999 options.changes_by_issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001000 options.changes = True
1001 options.issues = True
1002 options.reviews = True
1003
1004 # First do any required authentication so none of the user interaction has to
1005 # wait for actual work.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001006 if options.changes or options.changes_by_issue:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001007 my_activity.auth_for_changes()
1008 if options.reviews:
1009 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001010
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001011 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001012
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001013 try:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001014 if options.changes or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001015 my_activity.get_changes()
1016 if options.reviews:
1017 my_activity.get_reviews()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001018 if options.issues or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001019 my_activity.get_issues()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001020 if not options.no_referenced_issues:
1021 my_activity.get_referenced_issues()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001022 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001023 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001024
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001025 my_activity.show_progress('\n')
1026
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001027 output_file = None
1028 try:
1029 if options.output:
1030 output_file = open(options.output, 'w')
1031 logging.info('Printing output to "%s"', options.output)
1032 sys.stdout = output_file
1033 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -07001034 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001035 else:
1036 if options.json:
1037 my_activity.dump_json()
1038 else:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001039 if options.changes:
1040 my_activity.print_changes()
1041 if options.reviews:
1042 my_activity.print_reviews()
1043 if options.issues:
1044 my_activity.print_issues()
1045 if options.changes_by_issue:
1046 my_activity.print_changes_by_issue(
1047 options.skip_own_issues_without_changes)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001048 finally:
1049 if output_file:
1050 logging.info('Done printing to file.')
1051 sys.stdout = sys.__stdout__
1052 output_file.close()
1053
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001054 return 0
1055
1056
1057if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001058 # Fix encoding to support non-ascii issue titles.
1059 fix_encoding.fix_encoding()
1060
sbc@chromium.org013731e2015-02-26 18:28:43 +00001061 try:
1062 sys.exit(main())
1063 except KeyboardInterrupt:
1064 sys.stderr.write('interrupted\n')
1065 sys.exit(1)