blob: 48f14055d551910c70aa7ebb5f1f42a741f95b4b [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'],
598 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000599
600 def print_issue(self, issue):
601 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000602 'created': issue['created'].date().isoformat(),
603 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000604 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000605 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000606 }
607 self.print_generic(self.options.output_format,
608 self.options.output_format_issues,
609 issue['header'],
610 issue['url'],
611 issue['author'],
612 optional_values)
613
614 def print_review(self, review):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000615 activity = len([
616 reply
617 for reply in review['replies']
618 if self.match(reply['author'])
619 ])
620 optional_values = {
621 'created': review['created'].date().isoformat(),
622 'modified': review['modified'].date().isoformat(),
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800623 'status': review['status'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000624 'activity': activity,
625 }
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800626 if self.options.deltas:
627 optional_values['delta'] = review['delta']
628
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000629 self.print_generic(self.options.output_format,
630 self.options.output_format_reviews,
631 review['header'],
632 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000633 review['author'],
634 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000635
636 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000637 def print_generic(default_fmt, specific_fmt,
638 title, url, author,
639 optional_values=None):
640 output_format = specific_fmt if specific_fmt is not None else default_fmt
641 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000642 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000643 'title': title,
644 'url': url,
645 'author': author,
646 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000647 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000648 values.update(optional_values)
649 print DefaultFormatter().format(output_format, **values).encode(
650 sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000651
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000652
653 def filter_issue(self, issue, should_filter_by_user=True):
654 def maybe_filter_username(email):
655 return not should_filter_by_user or username(email) == self.user
656 if (maybe_filter_username(issue['author']) and
657 self.filter_modified(issue['created'])):
658 return True
659 if (maybe_filter_username(issue['owner']) and
660 (self.filter_modified(issue['created']) or
661 self.filter_modified(issue['modified']))):
662 return True
663 for reply in issue['replies']:
664 if self.filter_modified(reply['created']):
665 if not should_filter_by_user:
666 break
667 if (username(reply['author']) == self.user
668 or (self.user + '@') in reply['content']):
669 break
670 else:
671 return False
672 return True
673
674 def filter_modified(self, modified):
675 return self.modified_after < modified and modified < self.modified_before
676
677 def auth_for_changes(self):
678 #TODO(cjhopman): Move authentication check for getting changes here.
679 pass
680
681 def auth_for_reviews(self):
682 # Reviews use all the same instances as changes so no authentication is
683 # required.
684 pass
685
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000686 def get_changes(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100687 num_instances = len(rietveld_instances) + len(gerrit_instances)
688 with contextlib.closing(ThreadPool(num_instances)) as pool:
689 rietveld_changes = pool.map_async(
690 lambda instance: self.rietveld_search(instance, owner=self.user),
691 rietveld_instances)
692 gerrit_changes = pool.map_async(
693 lambda instance: self.gerrit_search(instance, owner=self.user),
694 gerrit_instances)
695 rietveld_changes = itertools.chain.from_iterable(rietveld_changes.get())
696 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
697 self.changes = list(rietveld_changes) + list(gerrit_changes)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000698
699 def print_changes(self):
700 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000701 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000702 for change in self.changes:
Bruce Dawson2afcf222019-02-25 22:07:08 +0000703 self.print_change(change)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000704
Vadim Bendebury8de38002018-05-14 19:02:55 -0700705 def print_access_errors(self):
706 if self.access_errors:
Ryan Harrison398fb442018-05-22 12:05:26 -0400707 logging.error('Access Errors:')
708 for error in self.access_errors:
709 logging.error(error.rstrip())
Vadim Bendebury8de38002018-05-14 19:02:55 -0700710
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000711 def get_reviews(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100712 num_instances = len(rietveld_instances) + len(gerrit_instances)
713 with contextlib.closing(ThreadPool(num_instances)) as pool:
714 rietveld_reviews = pool.map_async(
715 lambda instance: self.rietveld_search(instance, reviewer=self.user),
716 rietveld_instances)
717 gerrit_reviews = pool.map_async(
718 lambda instance: self.gerrit_search(instance, reviewer=self.user),
719 gerrit_instances)
720 rietveld_reviews = itertools.chain.from_iterable(rietveld_reviews.get())
721 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100722 self.reviews = list(rietveld_reviews) + list(gerrit_reviews)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000723
724 def print_reviews(self):
725 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000726 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000727 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000728 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000729
730 def get_issues(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100731 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
732 monorail_issues = pool.map(
733 self.monorail_issue_search, monorail_projects.keys())
734 monorail_issues = list(itertools.chain.from_iterable(monorail_issues))
735
Vadim Bendeburycbf02042018-05-14 17:46:15 -0700736 if not monorail_issues:
737 return
738
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100739 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
740 filtered_issues = pool.map(
741 self.filter_modified_monorail_issue, monorail_issues)
742 self.issues = [issue for issue in filtered_issues if issue]
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100743
744 def get_referenced_issues(self):
745 if not self.issues:
746 self.get_issues()
747
748 if not self.changes:
749 self.get_changes()
750
751 referenced_issue_uids = set(itertools.chain.from_iterable(
752 change['bugs'] for change in self.changes))
753 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
754 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
755
756 missing_issues_by_project = collections.defaultdict(list)
757 for issue_uid in missing_issue_uids:
758 project, issue_id = issue_uid.split(':')
759 missing_issues_by_project[project].append(issue_id)
760
761 for project, issue_ids in missing_issues_by_project.iteritems():
762 self.referenced_issues += self.monorail_get_issues(project, issue_ids)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000763
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000764 def print_issues(self):
765 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000766 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000767 for issue in self.issues:
768 self.print_issue(issue)
769
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100770 def print_changes_by_issue(self, skip_empty_own):
771 if not self.issues or not self.changes:
772 return
773
774 self.print_heading('Changes by referenced issue(s)')
775 issues = {issue['uid']: issue for issue in self.issues}
776 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
777 changes_by_issue_uid = collections.defaultdict(list)
778 changes_by_ref_issue_uid = collections.defaultdict(list)
779 changes_without_issue = []
780 for change in self.changes:
781 added = False
Andrii Shyshkalov483580b2018-07-02 06:55:10 +0000782 for issue_uid in change['bugs']:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100783 if issue_uid in issues:
784 changes_by_issue_uid[issue_uid].append(change)
785 added = True
786 if issue_uid in ref_issues:
787 changes_by_ref_issue_uid[issue_uid].append(change)
788 added = True
789 if not added:
790 changes_without_issue.append(change)
791
792 # Changes referencing own issues.
793 for issue_uid in issues:
794 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
795 self.print_issue(issues[issue_uid])
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +0000796 if changes_by_issue_uid[issue_uid]:
797 print
798 for change in changes_by_issue_uid[issue_uid]:
799 print ' ', # this prints one space due to comma, but no newline
800 self.print_change(change)
801 print
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100802
803 # Changes referencing others' issues.
804 for issue_uid in ref_issues:
805 assert changes_by_ref_issue_uid[issue_uid]
806 self.print_issue(ref_issues[issue_uid])
807 for change in changes_by_ref_issue_uid[issue_uid]:
808 print '', # this prints one space due to comma, but no newline
809 self.print_change(change)
810
811 # Changes referencing no issues.
812 if changes_without_issue:
813 print self.options.output_format_no_url.format(title='Other changes')
814 for change in changes_without_issue:
815 print '', # this prints one space due to comma, but no newline
816 self.print_change(change)
817
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000818 def print_activity(self):
819 self.print_changes()
820 self.print_reviews()
821 self.print_issues()
822
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000823 def dump_json(self, ignore_keys=None):
824 if ignore_keys is None:
825 ignore_keys = ['replies']
826
827 def format_for_json_dump(in_array):
828 output = {}
829 for item in in_array:
830 url = item.get('url') or item.get('review_url')
831 if not url:
832 raise Exception('Dumped item %s does not specify url' % item)
833 output[url] = dict(
834 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
835 return output
836
837 class PythonObjectEncoder(json.JSONEncoder):
838 def default(self, obj): # pylint: disable=method-hidden
839 if isinstance(obj, datetime):
840 return obj.isoformat()
841 if isinstance(obj, set):
842 return list(obj)
843 return json.JSONEncoder.default(self, obj)
844
845 output = {
846 'reviews': format_for_json_dump(self.reviews),
847 'changes': format_for_json_dump(self.changes),
848 'issues': format_for_json_dump(self.issues)
849 }
850 print json.dumps(output, indent=2, cls=PythonObjectEncoder)
851
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000852
853def main():
854 # Silence upload.py.
855 rietveld.upload.verbosity = 0
856
857 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
858 parser.add_option(
859 '-u', '--user', metavar='<email>',
Bruce Dawson84370962019-02-21 05:33:37 +0000860 # Look for USER and USERNAME (Windows) environment variables.
861 default=os.environ.get('USER', os.environ.get('USERNAME')),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000862 help='Filter on user, default=%default')
863 parser.add_option(
864 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000865 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000866 parser.add_option(
867 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000868 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000869 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
870 relativedelta(months=2))
871 parser.add_option(
872 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000873 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000874 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
875 parser.add_option(
876 '-Y', '--this_year', action='store_true',
877 help='Use this year\'s dates')
878 parser.add_option(
879 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000880 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000881 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000882 '-W', '--last_week', action='count',
883 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000884 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000885 '-a', '--auth',
886 action='store_true',
887 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000888 parser.add_option(
889 '-d', '--deltas',
890 action='store_true',
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800891 help='Fetch deltas for changes.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100892 parser.add_option(
893 '--no-referenced-issues',
894 action='store_true',
895 help='Do not fetch issues referenced by owned changes. Useful in '
896 'combination with --changes-by-issue when you only want to list '
Sergiy Byelozyorovb4475ab2018-03-23 17:49:34 +0100897 'issues that have also been modified in the same time period.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100898 parser.add_option(
899 '--skip-own-issues-without-changes',
900 action='store_true',
901 help='Skips listing own issues without changes when showing changes '
902 'grouped by referenced issue(s). See --changes-by-issue for more '
903 'details.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000904
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000905 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000906 'By default, all activity will be looked up and '
907 'printed. If any of these are specified, only '
908 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000909 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000910 '-c', '--changes',
911 action='store_true',
912 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000913 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000914 '-i', '--issues',
915 action='store_true',
916 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000917 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000918 '-r', '--reviews',
919 action='store_true',
920 help='Show reviews.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100921 activity_types_group.add_option(
922 '--changes-by-issue', action='store_true',
923 help='Show changes grouped by referenced issue(s).')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000924 parser.add_option_group(activity_types_group)
925
926 output_format_group = optparse.OptionGroup(parser, 'Output Format',
927 'By default, all activity will be printed in the '
928 'following format: {url} {title}. This can be '
929 'changed for either all activity types or '
930 'individually for each activity type. The format '
931 'is defined as documented for '
932 'string.format(...). The variables available for '
933 'all activity types are url, title and author. '
934 'Format options for specific activity types will '
935 'override the generic format.')
936 output_format_group.add_option(
937 '-f', '--output-format', metavar='<format>',
938 default=u'{url} {title}',
939 help='Specifies the format to use when printing all your activity.')
940 output_format_group.add_option(
941 '--output-format-changes', metavar='<format>',
942 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000943 help='Specifies the format to use when printing changes. Supports the '
944 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000945 output_format_group.add_option(
946 '--output-format-issues', metavar='<format>',
947 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000948 help='Specifies the format to use when printing issues. Supports the '
949 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000950 output_format_group.add_option(
951 '--output-format-reviews', metavar='<format>',
952 default=None,
953 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000954 output_format_group.add_option(
955 '--output-format-heading', metavar='<format>',
956 default=u'{heading}:',
957 help='Specifies the format to use when printing headings.')
958 output_format_group.add_option(
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100959 '--output-format-no-url', default='{title}',
960 help='Specifies the format to use when printing activity without url.')
961 output_format_group.add_option(
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000962 '-m', '--markdown', action='store_true',
963 help='Use markdown-friendly output (overrides --output-format '
964 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000965 output_format_group.add_option(
966 '-j', '--json', action='store_true',
967 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000968 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000969 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000970
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000971 parser.add_option(
972 '-v', '--verbose',
973 action='store_const',
974 dest='verbosity',
975 default=logging.WARN,
976 const=logging.INFO,
977 help='Output extra informational messages.'
978 )
979 parser.add_option(
980 '-q', '--quiet',
981 action='store_const',
982 dest='verbosity',
983 const=logging.ERROR,
984 help='Suppress non-error messages.'
985 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000986 parser.add_option(
Peter K. Lee9342ac02018-08-28 21:03:51 +0000987 '-M', '--merged-only',
988 action='store_true',
989 dest='merged_only',
990 default=False,
991 help='Shows only changes that have been merged.')
992 parser.add_option(
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +0000993 '-C', '--completed-issues',
994 action='store_true',
995 dest='completed_issues',
996 default=False,
997 help='Shows only monorail issues that have completed (Fixed|Verified) '
998 'by the user.')
999 parser.add_option(
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001000 '-o', '--output', metavar='<file>',
1001 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001002
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001003 # Remove description formatting
1004 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -08001005 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001006
1007 options, args = parser.parse_args()
1008 options.local_user = os.environ.get('USER')
1009 if args:
1010 parser.error('Args unsupported')
1011 if not options.user:
Bruce Dawson84370962019-02-21 05:33:37 +00001012 parser.error('USER/USERNAME is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001013 options.user = username(options.user)
1014
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001015 logging.basicConfig(level=options.verbosity)
1016
1017 # python-keyring provides easy access to the system keyring.
1018 try:
1019 import keyring # pylint: disable=unused-import,unused-variable,F0401
1020 except ImportError:
1021 logging.warning('Consider installing python-keyring')
1022
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001023 if not options.begin:
1024 if options.last_quarter:
1025 begin, end = quarter_begin, quarter_end
1026 elif options.this_year:
1027 begin, end = get_year_of(datetime.today())
1028 elif options.week_of:
1029 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +00001030 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +00001031 begin, end = (get_week_of(datetime.today() -
1032 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001033 else:
1034 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
1035 else:
Daniel Cheng4b37ce62017-09-07 12:00:02 -07001036 begin = dateutil.parser.parse(options.begin)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001037 if options.end:
Daniel Cheng4b37ce62017-09-07 12:00:02 -07001038 end = dateutil.parser.parse(options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001039 else:
1040 end = datetime.today()
1041 options.begin, options.end = begin, end
Bruce Dawson2afcf222019-02-25 22:07:08 +00001042 if begin >= end:
1043 # The queries fail in peculiar ways when the begin date is in the future.
1044 # Give a descriptive error message instead.
1045 logging.error('Start date (%s) is the same or later than end date (%s)' %
1046 (begin, end))
1047 return 1
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001048
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +00001049 if options.markdown:
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +00001050 options.output_format_heading = '### {heading}\n'
1051 options.output_format = ' * [{title}]({url})'
1052 options.output_format_no_url = ' * {title}'
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001053 logging.info('Searching for activity by %s', options.user)
1054 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001055
1056 my_activity = MyActivity(options)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001057 my_activity.show_progress('Loading data')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001058
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001059 if not (options.changes or options.reviews or options.issues or
1060 options.changes_by_issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001061 options.changes = True
1062 options.issues = True
1063 options.reviews = True
1064
1065 # First do any required authentication so none of the user interaction has to
1066 # wait for actual work.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001067 if options.changes or options.changes_by_issue:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001068 my_activity.auth_for_changes()
1069 if options.reviews:
1070 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001071
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001072 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001073
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001074 try:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001075 if options.changes or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001076 my_activity.get_changes()
1077 if options.reviews:
1078 my_activity.get_reviews()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001079 if options.issues or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001080 my_activity.get_issues()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001081 if not options.no_referenced_issues:
1082 my_activity.get_referenced_issues()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001083 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001084 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001085
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001086 my_activity.show_progress('\n')
1087
Vadim Bendebury8de38002018-05-14 19:02:55 -07001088 my_activity.print_access_errors()
1089
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001090 output_file = None
1091 try:
1092 if options.output:
1093 output_file = open(options.output, 'w')
1094 logging.info('Printing output to "%s"', options.output)
1095 sys.stdout = output_file
1096 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -07001097 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001098 else:
1099 if options.json:
1100 my_activity.dump_json()
1101 else:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001102 if options.changes:
1103 my_activity.print_changes()
1104 if options.reviews:
1105 my_activity.print_reviews()
1106 if options.issues:
1107 my_activity.print_issues()
1108 if options.changes_by_issue:
1109 my_activity.print_changes_by_issue(
1110 options.skip_own_issues_without_changes)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001111 finally:
1112 if output_file:
1113 logging.info('Done printing to file.')
1114 sys.stdout = sys.__stdout__
1115 output_file.close()
1116
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001117 return 0
1118
1119
1120if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001121 # Fix encoding to support non-ascii issue titles.
1122 fix_encoding.fix_encoding()
1123
sbc@chromium.org013731e2015-02-26 18:28:43 +00001124 try:
1125 sys.exit(main())
1126 except KeyboardInterrupt:
1127 sys.stderr.write('interrupted\n')
1128 sys.exit(1)