blob: 0499286c86fc209d4f848f9cc7c6d124ad347da7 [file] [log] [blame]
Gabriel Charettebc6617a2019-02-05 21:30:52 +00001#!/usr/bin/env vpython
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00002# 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
Gabriel Charettebc6617a2019-02-05 21:30:52 +000024# [VPYTHON:BEGIN]
25# wheel: <
26# name: "infra/python/wheels/python-dateutil-py2_py3"
27# version: "version:2.7.3"
28# >
29# wheel: <
30# name: "infra/python/wheels/six-py2_py3"
31# version: "version:1.10.0"
32# >
33# [VPYTHON:END]
34
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +010035import collections
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +010036import contextlib
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000037from datetime import datetime
38from datetime import timedelta
39from functools import partial
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +010040import itertools
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000041import json
Tobias Sargeantffb3c432017-03-08 14:09:14 +000042import logging
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +010043from multiprocessing.pool import ThreadPool
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000044import optparse
45import os
46import subprocess
Tobias Sargeantffb3c432017-03-08 14:09:14 +000047from string import Formatter
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000048import sys
49import urllib
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000050import re
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000051
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000052import auth
stevefung@chromium.org832d51e2015-05-27 12:52:51 +000053import fix_encoding
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000054import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000055import rietveld
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000056
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000057from third_party import httplib2
58
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000059try:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000060 import dateutil # pylint: disable=import-error
61 import dateutil.parser
62 from dateutil.relativedelta import relativedelta
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000063except ImportError:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000064 logging.error('python-dateutil package required')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000065 exit(1)
66
Tobias Sargeantffb3c432017-03-08 14:09:14 +000067
68class DefaultFormatter(Formatter):
69 def __init__(self, default = ''):
70 super(DefaultFormatter, self).__init__()
71 self.default = default
72
73 def get_value(self, key, args, kwds):
74 if isinstance(key, basestring) and key not in kwds:
75 return self.default
76 return Formatter.get_value(self, key, args, kwds)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000077
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000078rietveld_instances = [
79 {
80 'url': 'codereview.chromium.org',
81 'shorturl': 'crrev.com',
82 'supports_owner_modified_query': True,
83 'requires_auth': False,
84 'email_domain': 'chromium.org',
Varun Khanejad9f97bc2017-08-02 12:55:01 -070085 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000086 },
87 {
88 'url': 'chromereviews.googleplex.com',
89 'shorturl': 'go/chromerev',
90 'supports_owner_modified_query': True,
91 'requires_auth': True,
92 'email_domain': 'google.com',
93 },
94 {
95 'url': 'codereview.appspot.com',
96 'supports_owner_modified_query': True,
97 'requires_auth': False,
98 'email_domain': 'chromium.org',
99 },
100 {
101 'url': 'breakpad.appspot.com',
102 'supports_owner_modified_query': False,
103 'requires_auth': False,
104 'email_domain': 'chromium.org',
105 },
106]
107
108gerrit_instances = [
109 {
Adrienne Walker95d4c852018-09-27 20:28:12 +0000110 'url': 'android-review.googlesource.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000111 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000112 {
113 'url': 'chrome-internal-review.googlesource.com',
Jeremy Romanf475a472017-06-06 16:49:11 -0400114 'shorturl': 'crrev.com/i',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700115 'short_url_protocol': 'https',
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000116 },
deymo@chromium.org56dc57a2015-09-10 18:26:54 +0000117 {
Adrienne Walker95d4c852018-09-27 20:28:12 +0000118 'url': 'chromium-review.googlesource.com',
119 'shorturl': 'crrev.com/c',
120 'short_url_protocol': 'https',
deymo@chromium.org56dc57a2015-09-10 18:26:54 +0000121 },
Ryan Harrison897602a2017-09-18 16:23:41 -0400122 {
123 'url': 'pdfium-review.googlesource.com',
124 },
Adrienne Walker95d4c852018-09-27 20:28:12 +0000125 {
126 'url': 'skia-review.googlesource.com',
127 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000128]
129
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100130monorail_projects = {
131 'chromium': {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000132 'shorturl': 'crbug.com',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700133 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000134 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100135 'google-breakpad': {},
136 'gyp': {},
137 'skia': {},
138 'pdfium': {
Ryan Harrison897602a2017-09-18 16:23:41 -0400139 'shorturl': 'crbug.com/pdfium',
140 'short_url_protocol': 'https',
141 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100142 'v8': {
143 'shorturl': 'crbug.com/v8',
144 'short_url_protocol': 'https',
145 },
146}
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000147
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000148def username(email):
149 """Keeps the username of an email address."""
150 return email and email.split('@', 1)[0]
151
152
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000153def datetime_to_midnight(date):
154 return date - timedelta(hours=date.hour, minutes=date.minute,
155 seconds=date.second, microseconds=date.microsecond)
156
157
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000158def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000159 begin = (datetime_to_midnight(date) -
160 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000161 return begin, begin + relativedelta(months=3)
162
163
164def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000165 begin = (datetime_to_midnight(date) -
166 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000167 return begin, begin + relativedelta(years=1)
168
169
170def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000171 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000172 return begin, begin + timedelta(days=7)
173
174
175def get_yes_or_no(msg):
176 while True:
177 response = raw_input(msg + ' yes/no [no] ')
178 if response == 'y' or response == 'yes':
179 return True
180 elif not response or response == 'n' or response == 'no':
181 return False
182
183
deymo@chromium.org6c039202013-09-12 12:28:12 +0000184def datetime_from_gerrit(date_string):
185 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
186
187
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000188def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000189 try:
190 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
191 except ValueError:
192 # Sometimes rietveld returns a value without the milliseconds part, so we
193 # attempt to parse those cases as well.
194 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000195
196
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100197def datetime_from_monorail(date_string):
198 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000199
200
201class MyActivity(object):
202 def __init__(self, options):
203 self.options = options
204 self.modified_after = options.begin
205 self.modified_before = options.end
206 self.user = options.user
207 self.changes = []
208 self.reviews = []
209 self.issues = []
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100210 self.referenced_issues = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000211 self.check_cookies()
212 self.google_code_auth_token = None
Vadim Bendebury8de38002018-05-14 19:02:55 -0700213 self.access_errors = set()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000214
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100215 def show_progress(self, how='.'):
216 if sys.stdout.isatty():
217 sys.stdout.write(how)
218 sys.stdout.flush()
219
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000220 # Check the codereview cookie jar to determine which Rietveld instances to
221 # authenticate to.
222 def check_cookies(self):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000223 filtered_instances = []
224
225 def has_cookie(instance):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000226 auth_config = auth.extract_auth_config_from_options(self.options)
227 a = auth.get_authenticator_for_host(instance['url'], auth_config)
228 return a.has_cached_credentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000229
230 for instance in rietveld_instances:
231 instance['auth'] = has_cookie(instance)
232
233 if filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000234 logging.warning('No cookie found for the following Rietveld instance%s:',
235 's' if len(filtered_instances) > 1 else '')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000236 for instance in filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000237 logging.warning('\t' + instance['url'])
238 logging.warning('Use --auth if you would like to authenticate to them.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000239
240 def rietveld_search(self, instance, owner=None, reviewer=None):
241 if instance['requires_auth'] and not instance['auth']:
242 return []
243
244
245 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000246 auth_config = auth.extract_auth_config_from_options(self.options)
247 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000248
249 # See def search() in rietveld.py to see all the filters you can use.
250 query_modified_after = None
251
252 if instance['supports_owner_modified_query']:
253 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
254
255 # Rietveld does not allow search by both created_before and modified_after.
256 # (And some instances don't allow search by both owner and modified_after)
257 owner_email = None
258 reviewer_email = None
259 if owner:
260 owner_email = owner + '@' + instance['email_domain']
261 if reviewer:
262 reviewer_email = reviewer + '@' + instance['email_domain']
263 issues = remote.search(
264 owner=owner_email,
265 reviewer=reviewer_email,
266 modified_after=query_modified_after,
267 with_messages=True)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100268 self.show_progress()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000269
270 issues = filter(
271 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
272 issues)
273 issues = filter(
274 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
275 issues)
276
277 should_filter_by_user = True
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000278 issues = map(partial(self.process_rietveld_issue, remote, instance), issues)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000279 issues = filter(
280 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
281 issues)
282 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
283
284 return issues
285
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000286 def extract_bug_numbers_from_description(self, issue):
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000287 description = None
288
289 if 'description' in issue:
290 # Getting the description for Rietveld
291 description = issue['description']
292 elif 'revisions' in issue:
293 # Getting the description for REST Gerrit
294 revision = issue['revisions'][issue['current_revision']]
295 description = revision['commit']['message']
296
297 bugs = []
298 if description:
Nicolas Dossou-gbete903ea732017-07-10 16:46:59 +0100299 # Handle both "Bug: 99999" and "BUG=99999" bug notations
300 # Multiple bugs can be noted on a single line or in multiple ones.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100301 matches = re.findall(
302 r'BUG[=:]\s?((((?:[a-zA-Z0-9-]+:)?\d+)(,\s?)?)+)', description,
303 flags=re.IGNORECASE)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000304 if matches:
305 for match in matches:
306 bugs.extend(match[0].replace(' ', '').split(','))
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100307 # Add default chromium: prefix if none specified.
308 bugs = [bug if ':' in bug else 'chromium:%s' % bug for bug in bugs]
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000309
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000310 return sorted(set(bugs))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000311
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000312 def process_rietveld_issue(self, remote, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000313 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000314 if self.options.deltas:
315 patchset_props = remote.get_patchset_properties(
316 issue['issue'],
317 issue['patchsets'][-1])
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100318 self.show_progress()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000319 ret['delta'] = '+%d,-%d' % (
320 sum(f['num_added'] for f in patchset_props['files'].itervalues()),
321 sum(f['num_removed'] for f in patchset_props['files'].itervalues()))
322
323 if issue['landed_days_ago'] != 'unknown':
324 ret['status'] = 'committed'
325 elif issue['closed']:
326 ret['status'] = 'closed'
327 elif len(issue['reviewers']) and issue['all_required_reviewers_approved']:
328 ret['status'] = 'ready'
329 else:
330 ret['status'] = 'open'
331
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000332 ret['owner'] = issue['owner_email']
333 ret['author'] = ret['owner']
334
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000335 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000336
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000337 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700338 url = instance['shorturl']
339 protocol = instance.get('short_url_protocol', 'http')
340 else:
341 url = instance['url']
342 protocol = 'https'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000343
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700344 ret['review_url'] = '%s://%s/%d' % (protocol, url, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000345
346 # Rietveld sometimes has '\r\n' instead of '\n'.
347 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000348
349 ret['modified'] = datetime_from_rietveld(issue['modified'])
350 ret['created'] = datetime_from_rietveld(issue['created'])
351 ret['replies'] = self.process_rietveld_replies(issue['messages'])
352
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000353 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000354 ret['landed_days_ago'] = issue['landed_days_ago']
355
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000356 return ret
357
358 @staticmethod
359 def process_rietveld_replies(replies):
360 ret = []
361 for reply in replies:
362 r = {}
363 r['author'] = reply['sender']
364 r['created'] = datetime_from_rietveld(reply['date'])
365 r['content'] = ''
366 ret.append(r)
367 return ret
368
Vadim Bendebury8de38002018-05-14 19:02:55 -0700369 def gerrit_changes_over_rest(self, instance, filters):
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200370 # Convert the "key:value" filter to a list of (key, value) pairs.
371 req = list(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000372 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000373 # Instantiate the generator to force all the requests now and catch the
374 # errors here.
375 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000376 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS',
377 'CURRENT_REVISION', 'CURRENT_COMMIT']))
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000378 except gerrit_util.GerritError, e:
Vadim Bendebury8de38002018-05-14 19:02:55 -0700379 error_message = 'Looking up %r: %s' % (instance['url'], e)
380 if error_message not in self.access_errors:
381 self.access_errors.add(error_message)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000382 return []
383
deymo@chromium.org6c039202013-09-12 12:28:12 +0000384 def gerrit_search(self, instance, owner=None, reviewer=None):
385 max_age = datetime.today() - self.modified_after
Andrii Shyshkalov4aedec02018-09-17 16:31:17 +0000386 filters = ['-age:%ss' % (max_age.days * 24 * 3600 + max_age.seconds)]
387 if owner:
388 assert not reviewer
389 filters.append('owner:%s' % owner)
390 else:
391 filters.extend(('-owner:%s' % reviewer, 'reviewer:%s' % reviewer))
Peter K. Lee9342ac02018-08-28 21:03:51 +0000392 # TODO(cjhopman): Should abandoned changes be filtered out when
393 # merged_only is not enabled?
394 if self.options.merged_only:
395 filters.append('status:merged')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000396
Aaron Gable2979a872017-09-05 17:38:32 -0700397 issues = self.gerrit_changes_over_rest(instance, filters)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100398 self.show_progress()
Aaron Gable2979a872017-09-05 17:38:32 -0700399 issues = [self.process_gerrit_issue(instance, issue)
400 for issue in issues]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000401
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000402 issues = filter(self.filter_issue, issues)
403 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
404
405 return issues
406
Aaron Gable2979a872017-09-05 17:38:32 -0700407 def process_gerrit_issue(self, instance, issue):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000408 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000409 if self.options.deltas:
410 ret['delta'] = DefaultFormatter().format(
411 '+{insertions},-{deletions}',
412 **issue)
413 ret['status'] = issue['status']
deymo@chromium.org6c039202013-09-12 12:28:12 +0000414 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700415 protocol = instance.get('short_url_protocol', 'http')
416 url = instance['shorturl']
417 else:
418 protocol = 'https'
419 url = instance['url']
420 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
421
deymo@chromium.org6c039202013-09-12 12:28:12 +0000422 ret['header'] = issue['subject']
Don Garrett2ebf9fd2018-08-06 15:14:00 +0000423 ret['owner'] = issue['owner'].get('email', '')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000424 ret['author'] = ret['owner']
425 ret['created'] = datetime_from_gerrit(issue['created'])
426 ret['modified'] = datetime_from_gerrit(issue['updated'])
427 if 'messages' in issue:
Aaron Gable2979a872017-09-05 17:38:32 -0700428 ret['replies'] = self.process_gerrit_issue_replies(issue['messages'])
deymo@chromium.org6c039202013-09-12 12:28:12 +0000429 else:
430 ret['replies'] = []
431 ret['reviewers'] = set(r['author'] for r in ret['replies'])
432 ret['reviewers'].discard(ret['author'])
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000433 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000434 return ret
435
436 @staticmethod
Aaron Gable2979a872017-09-05 17:38:32 -0700437 def process_gerrit_issue_replies(replies):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000438 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000439 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
440 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000441 for reply in replies:
442 ret.append({
443 'author': reply['author']['email'],
444 'created': datetime_from_gerrit(reply['date']),
445 'content': reply['message'],
446 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000447 return ret
448
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100449 def monorail_get_auth_http(self):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000450 auth_config = auth.extract_auth_config_from_options(self.options)
451 authenticator = auth.get_authenticator_for_host(
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000452 'bugs.chromium.org', auth_config)
Kenneth Russell66badbd2018-09-09 21:35:32 +0000453 # Manually use a long timeout (10m); for some users who have a
454 # long history on the issue tracker, whatever the default timeout
455 # is is reached.
456 return authenticator.authorize(httplib2.Http(timeout=600))
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100457
458 def filter_modified_monorail_issue(self, issue):
459 """Precisely checks if an issue has been modified in the time range.
460
461 This fetches all issue comments to check if the issue has been modified in
462 the time range specified by user. This is needed because monorail only
463 allows filtering by last updated and published dates, which is not
464 sufficient to tell whether a given issue has been modified at some specific
465 time range. Any update to the issue is a reported as comment on Monorail.
466
467 Args:
468 issue: Issue dict as returned by monorail_query_issues method. In
469 particular, must have a key 'uid' formatted as 'project:issue_id'.
470
471 Returns:
472 Passed issue if modified, None otherwise.
473 """
474 http = self.monorail_get_auth_http()
475 project, issue_id = issue['uid'].split(':')
476 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
477 '/%s/issues/%s/comments?maxResults=10000') % (project, issue_id)
478 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100479 self.show_progress()
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100480 content = json.loads(body)
481 if not content:
482 logging.error('Unable to parse %s response from monorail.', project)
483 return issue
484
485 for item in content.get('items', []):
486 comment_published = datetime_from_monorail(item['published'])
487 if self.filter_modified(comment_published):
488 return issue
489
490 return None
491
492 def monorail_query_issues(self, project, query):
493 http = self.monorail_get_auth_http()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000494 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100495 '/%s/issues') % project
496 query_data = urllib.urlencode(query)
497 url = url + '?' + query_data
498 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100499 self.show_progress()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100500 content = json.loads(body)
501 if not content:
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100502 logging.error('Unable to parse %s response from monorail.', project)
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100503 return []
504
505 issues = []
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100506 project_config = monorail_projects.get(project, {})
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100507 for item in content.get('items', []):
508 if project_config.get('shorturl'):
509 protocol = project_config.get('short_url_protocol', 'http')
510 item_url = '%s://%s/%d' % (
511 protocol, project_config['shorturl'], item['id'])
512 else:
513 item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
514 project, item['id'])
515 issue = {
516 'uid': '%s:%s' % (project, item['id']),
517 'header': item['title'],
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100518 'created': datetime_from_monorail(item['published']),
519 'modified': datetime_from_monorail(item['updated']),
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100520 'author': item['author']['name'],
521 'url': item_url,
522 'comments': [],
523 'status': item['status'],
524 'labels': [],
525 'components': []
526 }
527 if 'owner' in item:
528 issue['owner'] = item['owner']['name']
529 else:
530 issue['owner'] = 'None'
531 if 'labels' in item:
532 issue['labels'] = item['labels']
533 if 'components' in item:
534 issue['components'] = item['components']
535 issues.append(issue)
536
537 return issues
538
539 def monorail_issue_search(self, project):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000540 epoch = datetime.utcfromtimestamp(0)
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +0000541 # TODO(tandrii): support non-chromium email, too.
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000542 user_str = '%s@chromium.org' % self.user
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000543
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100544 issues = self.monorail_query_issues(project, {
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000545 'maxResults': 10000,
546 'q': user_str,
547 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
548 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000549 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000550
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +0000551 if self.options.completed_issues:
552 return [
553 issue for issue in issues
554 if (self.match(issue['owner']) and
555 issue['status'].lower() in ('verified', 'fixed'))
556 ]
557
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100558 return [
559 issue for issue in issues
560 if issue['author'] == user_str or issue['owner'] == user_str]
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000561
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100562 def monorail_get_issues(self, project, issue_ids):
563 return self.monorail_query_issues(project, {
564 'maxResults': 10000,
565 'q': 'id:%s' % ','.join(issue_ids)
566 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000567
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000568 def print_heading(self, heading):
569 print
570 print self.options.output_format_heading.format(heading=heading)
571
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000572 def match(self, author):
573 if '@' in self.user:
574 return author == self.user
575 return author.startswith(self.user + '@')
576
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000577 def print_change(self, change):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000578 activity = len([
579 reply
580 for reply in change['replies']
581 if self.match(reply['author'])
582 ])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000583 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000584 'created': change['created'].date().isoformat(),
585 'modified': change['modified'].date().isoformat(),
586 'reviewers': ', '.join(change['reviewers']),
587 'status': change['status'],
588 'activity': activity,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000589 }
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000590 if self.options.deltas:
591 optional_values['delta'] = change['delta']
592
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000593 self.print_generic(self.options.output_format,
594 self.options.output_format_changes,
595 change['header'],
596 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000597 change['author'],
Lutz Justen860378e2019-03-12 01:10:02 +0000598 change['created'],
599 change['modified'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000600 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000601
602 def print_issue(self, issue):
603 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000604 'created': issue['created'].date().isoformat(),
605 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000606 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000607 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000608 }
609 self.print_generic(self.options.output_format,
610 self.options.output_format_issues,
611 issue['header'],
612 issue['url'],
613 issue['author'],
Lutz Justen860378e2019-03-12 01:10:02 +0000614 issue['created'],
615 issue['modified'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000616 optional_values)
617
618 def print_review(self, review):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000619 activity = len([
620 reply
621 for reply in review['replies']
622 if self.match(reply['author'])
623 ])
624 optional_values = {
625 'created': review['created'].date().isoformat(),
626 'modified': review['modified'].date().isoformat(),
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800627 'status': review['status'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000628 'activity': activity,
629 }
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800630 if self.options.deltas:
631 optional_values['delta'] = review['delta']
632
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000633 self.print_generic(self.options.output_format,
634 self.options.output_format_reviews,
635 review['header'],
636 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000637 review['author'],
Lutz Justen860378e2019-03-12 01:10:02 +0000638 review['created'],
639 review['modified'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000640 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000641
642 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000643 def print_generic(default_fmt, specific_fmt,
Lutz Justen860378e2019-03-12 01:10:02 +0000644 title, url, author, created, modified,
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000645 optional_values=None):
646 output_format = specific_fmt if specific_fmt is not None else default_fmt
647 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000648 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000649 'title': title,
650 'url': url,
651 'author': author,
Lutz Justen860378e2019-03-12 01:10:02 +0000652 'created': created,
653 'modified': modified,
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000654 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000655 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000656 values.update(optional_values)
657 print DefaultFormatter().format(output_format, **values).encode(
658 sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000659
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000660
661 def filter_issue(self, issue, should_filter_by_user=True):
662 def maybe_filter_username(email):
663 return not should_filter_by_user or username(email) == self.user
664 if (maybe_filter_username(issue['author']) and
665 self.filter_modified(issue['created'])):
666 return True
667 if (maybe_filter_username(issue['owner']) and
668 (self.filter_modified(issue['created']) or
669 self.filter_modified(issue['modified']))):
670 return True
671 for reply in issue['replies']:
672 if self.filter_modified(reply['created']):
673 if not should_filter_by_user:
674 break
675 if (username(reply['author']) == self.user
676 or (self.user + '@') in reply['content']):
677 break
678 else:
679 return False
680 return True
681
682 def filter_modified(self, modified):
683 return self.modified_after < modified and modified < self.modified_before
684
685 def auth_for_changes(self):
686 #TODO(cjhopman): Move authentication check for getting changes here.
687 pass
688
689 def auth_for_reviews(self):
690 # Reviews use all the same instances as changes so no authentication is
691 # required.
692 pass
693
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000694 def get_changes(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100695 num_instances = len(rietveld_instances) + len(gerrit_instances)
696 with contextlib.closing(ThreadPool(num_instances)) as pool:
697 rietveld_changes = pool.map_async(
698 lambda instance: self.rietveld_search(instance, owner=self.user),
699 rietveld_instances)
700 gerrit_changes = pool.map_async(
701 lambda instance: self.gerrit_search(instance, owner=self.user),
702 gerrit_instances)
703 rietveld_changes = itertools.chain.from_iterable(rietveld_changes.get())
704 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
705 self.changes = list(rietveld_changes) + list(gerrit_changes)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000706
707 def print_changes(self):
708 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000709 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000710 for change in self.changes:
Bruce Dawson2afcf222019-02-25 22:07:08 +0000711 self.print_change(change)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000712
Vadim Bendebury8de38002018-05-14 19:02:55 -0700713 def print_access_errors(self):
714 if self.access_errors:
Ryan Harrison398fb442018-05-22 12:05:26 -0400715 logging.error('Access Errors:')
716 for error in self.access_errors:
717 logging.error(error.rstrip())
Vadim Bendebury8de38002018-05-14 19:02:55 -0700718
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000719 def get_reviews(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100720 num_instances = len(rietveld_instances) + len(gerrit_instances)
721 with contextlib.closing(ThreadPool(num_instances)) as pool:
722 rietveld_reviews = pool.map_async(
723 lambda instance: self.rietveld_search(instance, reviewer=self.user),
724 rietveld_instances)
725 gerrit_reviews = pool.map_async(
726 lambda instance: self.gerrit_search(instance, reviewer=self.user),
727 gerrit_instances)
728 rietveld_reviews = itertools.chain.from_iterable(rietveld_reviews.get())
729 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100730 self.reviews = list(rietveld_reviews) + list(gerrit_reviews)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000731
732 def print_reviews(self):
733 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000734 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000735 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000736 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000737
738 def get_issues(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100739 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
740 monorail_issues = pool.map(
741 self.monorail_issue_search, monorail_projects.keys())
742 monorail_issues = list(itertools.chain.from_iterable(monorail_issues))
743
Vadim Bendeburycbf02042018-05-14 17:46:15 -0700744 if not monorail_issues:
745 return
746
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100747 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
748 filtered_issues = pool.map(
749 self.filter_modified_monorail_issue, monorail_issues)
750 self.issues = [issue for issue in filtered_issues if issue]
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100751
752 def get_referenced_issues(self):
753 if not self.issues:
754 self.get_issues()
755
756 if not self.changes:
757 self.get_changes()
758
759 referenced_issue_uids = set(itertools.chain.from_iterable(
760 change['bugs'] for change in self.changes))
761 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
762 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
763
764 missing_issues_by_project = collections.defaultdict(list)
765 for issue_uid in missing_issue_uids:
766 project, issue_id = issue_uid.split(':')
767 missing_issues_by_project[project].append(issue_id)
768
769 for project, issue_ids in missing_issues_by_project.iteritems():
770 self.referenced_issues += self.monorail_get_issues(project, issue_ids)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000771
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000772 def print_issues(self):
773 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000774 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000775 for issue in self.issues:
776 self.print_issue(issue)
777
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100778 def print_changes_by_issue(self, skip_empty_own):
779 if not self.issues or not self.changes:
780 return
781
782 self.print_heading('Changes by referenced issue(s)')
783 issues = {issue['uid']: issue for issue in self.issues}
784 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
785 changes_by_issue_uid = collections.defaultdict(list)
786 changes_by_ref_issue_uid = collections.defaultdict(list)
787 changes_without_issue = []
788 for change in self.changes:
789 added = False
Andrii Shyshkalov483580b2018-07-02 06:55:10 +0000790 for issue_uid in change['bugs']:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100791 if issue_uid in issues:
792 changes_by_issue_uid[issue_uid].append(change)
793 added = True
794 if issue_uid in ref_issues:
795 changes_by_ref_issue_uid[issue_uid].append(change)
796 added = True
797 if not added:
798 changes_without_issue.append(change)
799
800 # Changes referencing own issues.
801 for issue_uid in issues:
802 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
803 self.print_issue(issues[issue_uid])
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +0000804 if changes_by_issue_uid[issue_uid]:
805 print
806 for change in changes_by_issue_uid[issue_uid]:
807 print ' ', # this prints one space due to comma, but no newline
808 self.print_change(change)
809 print
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100810
811 # Changes referencing others' issues.
812 for issue_uid in ref_issues:
813 assert changes_by_ref_issue_uid[issue_uid]
814 self.print_issue(ref_issues[issue_uid])
815 for change in changes_by_ref_issue_uid[issue_uid]:
816 print '', # this prints one space due to comma, but no newline
817 self.print_change(change)
818
819 # Changes referencing no issues.
820 if changes_without_issue:
821 print self.options.output_format_no_url.format(title='Other changes')
822 for change in changes_without_issue:
823 print '', # this prints one space due to comma, but no newline
824 self.print_change(change)
825
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000826 def print_activity(self):
827 self.print_changes()
828 self.print_reviews()
829 self.print_issues()
830
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000831 def dump_json(self, ignore_keys=None):
832 if ignore_keys is None:
833 ignore_keys = ['replies']
834
835 def format_for_json_dump(in_array):
836 output = {}
837 for item in in_array:
838 url = item.get('url') or item.get('review_url')
839 if not url:
840 raise Exception('Dumped item %s does not specify url' % item)
841 output[url] = dict(
842 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
843 return output
844
845 class PythonObjectEncoder(json.JSONEncoder):
846 def default(self, obj): # pylint: disable=method-hidden
847 if isinstance(obj, datetime):
848 return obj.isoformat()
849 if isinstance(obj, set):
850 return list(obj)
851 return json.JSONEncoder.default(self, obj)
852
853 output = {
854 'reviews': format_for_json_dump(self.reviews),
855 'changes': format_for_json_dump(self.changes),
856 'issues': format_for_json_dump(self.issues)
857 }
858 print json.dumps(output, indent=2, cls=PythonObjectEncoder)
859
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000860
861def main():
862 # Silence upload.py.
863 rietveld.upload.verbosity = 0
864
865 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
866 parser.add_option(
867 '-u', '--user', metavar='<email>',
Bruce Dawson84370962019-02-21 05:33:37 +0000868 # Look for USER and USERNAME (Windows) environment variables.
869 default=os.environ.get('USER', os.environ.get('USERNAME')),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000870 help='Filter on user, default=%default')
871 parser.add_option(
872 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000873 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000874 parser.add_option(
875 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000876 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000877 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
878 relativedelta(months=2))
879 parser.add_option(
880 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000881 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000882 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
883 parser.add_option(
884 '-Y', '--this_year', action='store_true',
885 help='Use this year\'s dates')
886 parser.add_option(
887 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000888 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000889 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000890 '-W', '--last_week', action='count',
891 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000892 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000893 '-a', '--auth',
894 action='store_true',
895 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000896 parser.add_option(
897 '-d', '--deltas',
898 action='store_true',
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800899 help='Fetch deltas for changes.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100900 parser.add_option(
901 '--no-referenced-issues',
902 action='store_true',
903 help='Do not fetch issues referenced by owned changes. Useful in '
904 'combination with --changes-by-issue when you only want to list '
Sergiy Byelozyorovb4475ab2018-03-23 17:49:34 +0100905 'issues that have also been modified in the same time period.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100906 parser.add_option(
907 '--skip-own-issues-without-changes',
908 action='store_true',
909 help='Skips listing own issues without changes when showing changes '
910 'grouped by referenced issue(s). See --changes-by-issue for more '
911 'details.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000912
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000913 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000914 'By default, all activity will be looked up and '
915 'printed. If any of these are specified, only '
916 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000917 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000918 '-c', '--changes',
919 action='store_true',
920 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000921 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000922 '-i', '--issues',
923 action='store_true',
924 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000925 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000926 '-r', '--reviews',
927 action='store_true',
928 help='Show reviews.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100929 activity_types_group.add_option(
930 '--changes-by-issue', action='store_true',
931 help='Show changes grouped by referenced issue(s).')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000932 parser.add_option_group(activity_types_group)
933
934 output_format_group = optparse.OptionGroup(parser, 'Output Format',
935 'By default, all activity will be printed in the '
936 'following format: {url} {title}. This can be '
937 'changed for either all activity types or '
938 'individually for each activity type. The format '
939 'is defined as documented for '
940 'string.format(...). The variables available for '
Lutz Justen860378e2019-03-12 01:10:02 +0000941 'all activity types are url, title, author, '
942 'created and modified. Format options for '
943 'specific activity types will override the '
944 'generic format.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000945 output_format_group.add_option(
946 '-f', '--output-format', metavar='<format>',
947 default=u'{url} {title}',
948 help='Specifies the format to use when printing all your activity.')
949 output_format_group.add_option(
950 '--output-format-changes', metavar='<format>',
951 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000952 help='Specifies the format to use when printing changes. Supports the '
953 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000954 output_format_group.add_option(
955 '--output-format-issues', metavar='<format>',
956 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000957 help='Specifies the format to use when printing issues. Supports the '
958 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000959 output_format_group.add_option(
960 '--output-format-reviews', metavar='<format>',
961 default=None,
962 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000963 output_format_group.add_option(
964 '--output-format-heading', metavar='<format>',
965 default=u'{heading}:',
966 help='Specifies the format to use when printing headings.')
967 output_format_group.add_option(
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100968 '--output-format-no-url', default='{title}',
969 help='Specifies the format to use when printing activity without url.')
970 output_format_group.add_option(
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000971 '-m', '--markdown', action='store_true',
972 help='Use markdown-friendly output (overrides --output-format '
973 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000974 output_format_group.add_option(
975 '-j', '--json', action='store_true',
976 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000977 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000978 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000979
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000980 parser.add_option(
981 '-v', '--verbose',
982 action='store_const',
983 dest='verbosity',
984 default=logging.WARN,
985 const=logging.INFO,
986 help='Output extra informational messages.'
987 )
988 parser.add_option(
989 '-q', '--quiet',
990 action='store_const',
991 dest='verbosity',
992 const=logging.ERROR,
993 help='Suppress non-error messages.'
994 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000995 parser.add_option(
Peter K. Lee9342ac02018-08-28 21:03:51 +0000996 '-M', '--merged-only',
997 action='store_true',
998 dest='merged_only',
999 default=False,
1000 help='Shows only changes that have been merged.')
1001 parser.add_option(
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +00001002 '-C', '--completed-issues',
1003 action='store_true',
1004 dest='completed_issues',
1005 default=False,
1006 help='Shows only monorail issues that have completed (Fixed|Verified) '
1007 'by the user.')
1008 parser.add_option(
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001009 '-o', '--output', metavar='<file>',
1010 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001011
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001012 # Remove description formatting
1013 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -08001014 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001015
1016 options, args = parser.parse_args()
1017 options.local_user = os.environ.get('USER')
1018 if args:
1019 parser.error('Args unsupported')
1020 if not options.user:
Bruce Dawson84370962019-02-21 05:33:37 +00001021 parser.error('USER/USERNAME is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001022 options.user = username(options.user)
1023
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001024 logging.basicConfig(level=options.verbosity)
1025
1026 # python-keyring provides easy access to the system keyring.
1027 try:
1028 import keyring # pylint: disable=unused-import,unused-variable,F0401
1029 except ImportError:
1030 logging.warning('Consider installing python-keyring')
1031
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001032 if not options.begin:
1033 if options.last_quarter:
1034 begin, end = quarter_begin, quarter_end
1035 elif options.this_year:
1036 begin, end = get_year_of(datetime.today())
1037 elif options.week_of:
1038 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +00001039 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +00001040 begin, end = (get_week_of(datetime.today() -
1041 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001042 else:
1043 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
1044 else:
Daniel Cheng4b37ce62017-09-07 12:00:02 -07001045 begin = dateutil.parser.parse(options.begin)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001046 if options.end:
Daniel Cheng4b37ce62017-09-07 12:00:02 -07001047 end = dateutil.parser.parse(options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001048 else:
1049 end = datetime.today()
1050 options.begin, options.end = begin, end
Bruce Dawson2afcf222019-02-25 22:07:08 +00001051 if begin >= end:
1052 # The queries fail in peculiar ways when the begin date is in the future.
1053 # Give a descriptive error message instead.
1054 logging.error('Start date (%s) is the same or later than end date (%s)' %
1055 (begin, end))
1056 return 1
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001057
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +00001058 if options.markdown:
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +00001059 options.output_format_heading = '### {heading}\n'
1060 options.output_format = ' * [{title}]({url})'
1061 options.output_format_no_url = ' * {title}'
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001062 logging.info('Searching for activity by %s', options.user)
1063 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001064
1065 my_activity = MyActivity(options)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001066 my_activity.show_progress('Loading data')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001067
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001068 if not (options.changes or options.reviews or options.issues or
1069 options.changes_by_issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001070 options.changes = True
1071 options.issues = True
1072 options.reviews = True
1073
1074 # First do any required authentication so none of the user interaction has to
1075 # wait for actual work.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001076 if options.changes or options.changes_by_issue:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001077 my_activity.auth_for_changes()
1078 if options.reviews:
1079 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001080
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001081 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001082
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001083 try:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001084 if options.changes or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001085 my_activity.get_changes()
1086 if options.reviews:
1087 my_activity.get_reviews()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001088 if options.issues or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001089 my_activity.get_issues()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001090 if not options.no_referenced_issues:
1091 my_activity.get_referenced_issues()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001092 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001093 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001094
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001095 my_activity.show_progress('\n')
1096
Vadim Bendebury8de38002018-05-14 19:02:55 -07001097 my_activity.print_access_errors()
1098
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001099 output_file = None
1100 try:
1101 if options.output:
1102 output_file = open(options.output, 'w')
1103 logging.info('Printing output to "%s"', options.output)
1104 sys.stdout = output_file
1105 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -07001106 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001107 else:
1108 if options.json:
1109 my_activity.dump_json()
1110 else:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001111 if options.changes:
1112 my_activity.print_changes()
1113 if options.reviews:
1114 my_activity.print_reviews()
1115 if options.issues:
1116 my_activity.print_issues()
1117 if options.changes_by_issue:
1118 my_activity.print_changes_by_issue(
1119 options.skip_own_issues_without_changes)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001120 finally:
1121 if output_file:
1122 logging.info('Done printing to file.')
1123 sys.stdout = sys.__stdout__
1124 output_file.close()
1125
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001126 return 0
1127
1128
1129if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001130 # Fix encoding to support non-ascii issue titles.
1131 fix_encoding.fix_encoding()
1132
sbc@chromium.org013731e2015-02-26 18:28:43 +00001133 try:
1134 sys.exit(main())
1135 except KeyboardInterrupt:
1136 sys.stderr.write('interrupted\n')
1137 sys.exit(1)