blob: 581e7d91c1b4890e5a8f2599ffabbbe0ab86e697 [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
Raul Tambre80ee78e2019-05-06 22:41:05 +000035from __future__ import print_function
36
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +010037import collections
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +010038import contextlib
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000039from datetime import datetime
40from datetime import timedelta
41from functools import partial
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +010042import itertools
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000043import json
Tobias Sargeantffb3c432017-03-08 14:09:14 +000044import logging
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +010045from multiprocessing.pool import ThreadPool
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000046import optparse
47import os
48import subprocess
Tobias Sargeantffb3c432017-03-08 14:09:14 +000049from string import Formatter
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000050import sys
51import urllib
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000052import re
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000053
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000054import auth
stevefung@chromium.org832d51e2015-05-27 12:52:51 +000055import fix_encoding
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000056import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000057import rietveld
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000058
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000059from third_party import httplib2
60
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000061try:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000062 import dateutil # pylint: disable=import-error
63 import dateutil.parser
64 from dateutil.relativedelta import relativedelta
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000065except ImportError:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000066 logging.error('python-dateutil package required')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000067 exit(1)
68
Tobias Sargeantffb3c432017-03-08 14:09:14 +000069
70class DefaultFormatter(Formatter):
71 def __init__(self, default = ''):
72 super(DefaultFormatter, self).__init__()
73 self.default = default
74
75 def get_value(self, key, args, kwds):
76 if isinstance(key, basestring) and key not in kwds:
77 return self.default
78 return Formatter.get_value(self, key, args, kwds)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000079
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000080rietveld_instances = [
81 {
82 'url': 'codereview.chromium.org',
83 'shorturl': 'crrev.com',
84 'supports_owner_modified_query': True,
85 'requires_auth': False,
86 'email_domain': 'chromium.org',
Varun Khanejad9f97bc2017-08-02 12:55:01 -070087 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000088 },
89 {
90 'url': 'chromereviews.googleplex.com',
91 'shorturl': 'go/chromerev',
92 'supports_owner_modified_query': True,
93 'requires_auth': True,
94 'email_domain': 'google.com',
95 },
96 {
97 'url': 'codereview.appspot.com',
98 'supports_owner_modified_query': True,
99 'requires_auth': False,
100 'email_domain': 'chromium.org',
101 },
102 {
103 'url': 'breakpad.appspot.com',
104 'supports_owner_modified_query': False,
105 'requires_auth': False,
106 'email_domain': 'chromium.org',
107 },
108]
109
110gerrit_instances = [
111 {
Adrienne Walker95d4c852018-09-27 20:28:12 +0000112 'url': 'android-review.googlesource.com',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000113 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000114 {
115 'url': 'chrome-internal-review.googlesource.com',
Jeremy Romanf475a472017-06-06 16:49:11 -0400116 'shorturl': 'crrev.com/i',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700117 'short_url_protocol': 'https',
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000118 },
deymo@chromium.org56dc57a2015-09-10 18:26:54 +0000119 {
Adrienne Walker95d4c852018-09-27 20:28:12 +0000120 'url': 'chromium-review.googlesource.com',
121 'shorturl': 'crrev.com/c',
122 'short_url_protocol': 'https',
deymo@chromium.org56dc57a2015-09-10 18:26:54 +0000123 },
Ryan Harrison897602a2017-09-18 16:23:41 -0400124 {
125 'url': 'pdfium-review.googlesource.com',
126 },
Adrienne Walker95d4c852018-09-27 20:28:12 +0000127 {
128 'url': 'skia-review.googlesource.com',
129 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000130]
131
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100132monorail_projects = {
133 'chromium': {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000134 'shorturl': 'crbug.com',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700135 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000136 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100137 'google-breakpad': {},
138 'gyp': {},
139 'skia': {},
140 'pdfium': {
Ryan Harrison897602a2017-09-18 16:23:41 -0400141 'shorturl': 'crbug.com/pdfium',
142 'short_url_protocol': 'https',
143 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100144 'v8': {
145 'shorturl': 'crbug.com/v8',
146 'short_url_protocol': 'https',
147 },
148}
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000149
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000150def username(email):
151 """Keeps the username of an email address."""
152 return email and email.split('@', 1)[0]
153
154
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000155def datetime_to_midnight(date):
156 return date - timedelta(hours=date.hour, minutes=date.minute,
157 seconds=date.second, microseconds=date.microsecond)
158
159
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000160def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000161 begin = (datetime_to_midnight(date) -
162 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000163 return begin, begin + relativedelta(months=3)
164
165
166def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000167 begin = (datetime_to_midnight(date) -
168 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000169 return begin, begin + relativedelta(years=1)
170
171
172def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000173 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000174 return begin, begin + timedelta(days=7)
175
176
177def get_yes_or_no(msg):
178 while True:
179 response = raw_input(msg + ' yes/no [no] ')
180 if response == 'y' or response == 'yes':
181 return True
182 elif not response or response == 'n' or response == 'no':
183 return False
184
185
deymo@chromium.org6c039202013-09-12 12:28:12 +0000186def datetime_from_gerrit(date_string):
187 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
188
189
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000190def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000191 try:
192 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
193 except ValueError:
194 # Sometimes rietveld returns a value without the milliseconds part, so we
195 # attempt to parse those cases as well.
196 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000197
198
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100199def datetime_from_monorail(date_string):
200 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000201
202
203class MyActivity(object):
204 def __init__(self, options):
205 self.options = options
206 self.modified_after = options.begin
207 self.modified_before = options.end
208 self.user = options.user
209 self.changes = []
210 self.reviews = []
211 self.issues = []
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100212 self.referenced_issues = []
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000213 self.check_cookies()
214 self.google_code_auth_token = None
Vadim Bendebury8de38002018-05-14 19:02:55 -0700215 self.access_errors = set()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000216
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100217 def show_progress(self, how='.'):
218 if sys.stdout.isatty():
219 sys.stdout.write(how)
220 sys.stdout.flush()
221
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000222 # Check the codereview cookie jar to determine which Rietveld instances to
223 # authenticate to.
224 def check_cookies(self):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000225 filtered_instances = []
226
227 def has_cookie(instance):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000228 auth_config = auth.extract_auth_config_from_options(self.options)
229 a = auth.get_authenticator_for_host(instance['url'], auth_config)
230 return a.has_cached_credentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000231
232 for instance in rietveld_instances:
233 instance['auth'] = has_cookie(instance)
234
235 if filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000236 logging.warning('No cookie found for the following Rietveld instance%s:',
237 's' if len(filtered_instances) > 1 else '')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000238 for instance in filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000239 logging.warning('\t' + instance['url'])
240 logging.warning('Use --auth if you would like to authenticate to them.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000241
242 def rietveld_search(self, instance, owner=None, reviewer=None):
243 if instance['requires_auth'] and not instance['auth']:
244 return []
245
246
247 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000248 auth_config = auth.extract_auth_config_from_options(self.options)
249 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000250
251 # See def search() in rietveld.py to see all the filters you can use.
252 query_modified_after = None
253
254 if instance['supports_owner_modified_query']:
255 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
256
257 # Rietveld does not allow search by both created_before and modified_after.
258 # (And some instances don't allow search by both owner and modified_after)
259 owner_email = None
260 reviewer_email = None
261 if owner:
262 owner_email = owner + '@' + instance['email_domain']
263 if reviewer:
264 reviewer_email = reviewer + '@' + instance['email_domain']
265 issues = remote.search(
266 owner=owner_email,
267 reviewer=reviewer_email,
268 modified_after=query_modified_after,
269 with_messages=True)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100270 self.show_progress()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000271
272 issues = filter(
273 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
274 issues)
275 issues = filter(
276 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
277 issues)
278
279 should_filter_by_user = True
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000280 issues = map(partial(self.process_rietveld_issue, remote, instance), issues)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000281 issues = filter(
282 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
283 issues)
284 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
285
286 return issues
287
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000288 def extract_bug_numbers_from_description(self, issue):
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000289 description = None
290
291 if 'description' in issue:
292 # Getting the description for Rietveld
293 description = issue['description']
294 elif 'revisions' in issue:
295 # Getting the description for REST Gerrit
296 revision = issue['revisions'][issue['current_revision']]
297 description = revision['commit']['message']
298
299 bugs = []
300 if description:
Nicolas Dossou-gbete903ea732017-07-10 16:46:59 +0100301 # Handle both "Bug: 99999" and "BUG=99999" bug notations
302 # Multiple bugs can be noted on a single line or in multiple ones.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100303 matches = re.findall(
304 r'BUG[=:]\s?((((?:[a-zA-Z0-9-]+:)?\d+)(,\s?)?)+)', description,
305 flags=re.IGNORECASE)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000306 if matches:
307 for match in matches:
308 bugs.extend(match[0].replace(' ', '').split(','))
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100309 # Add default chromium: prefix if none specified.
310 bugs = [bug if ':' in bug else 'chromium:%s' % bug for bug in bugs]
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000311
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000312 return sorted(set(bugs))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000313
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000314 def process_rietveld_issue(self, remote, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000315 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000316 if self.options.deltas:
317 patchset_props = remote.get_patchset_properties(
318 issue['issue'],
319 issue['patchsets'][-1])
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100320 self.show_progress()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000321 ret['delta'] = '+%d,-%d' % (
322 sum(f['num_added'] for f in patchset_props['files'].itervalues()),
323 sum(f['num_removed'] for f in patchset_props['files'].itervalues()))
324
325 if issue['landed_days_ago'] != 'unknown':
326 ret['status'] = 'committed'
327 elif issue['closed']:
328 ret['status'] = 'closed'
329 elif len(issue['reviewers']) and issue['all_required_reviewers_approved']:
330 ret['status'] = 'ready'
331 else:
332 ret['status'] = 'open'
333
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000334 ret['owner'] = issue['owner_email']
335 ret['author'] = ret['owner']
336
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000337 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000338
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000339 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700340 url = instance['shorturl']
341 protocol = instance.get('short_url_protocol', 'http')
342 else:
343 url = instance['url']
344 protocol = 'https'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000345
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700346 ret['review_url'] = '%s://%s/%d' % (protocol, url, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000347
348 # Rietveld sometimes has '\r\n' instead of '\n'.
349 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000350
351 ret['modified'] = datetime_from_rietveld(issue['modified'])
352 ret['created'] = datetime_from_rietveld(issue['created'])
353 ret['replies'] = self.process_rietveld_replies(issue['messages'])
354
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000355 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000356 ret['landed_days_ago'] = issue['landed_days_ago']
357
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000358 return ret
359
360 @staticmethod
361 def process_rietveld_replies(replies):
362 ret = []
363 for reply in replies:
364 r = {}
365 r['author'] = reply['sender']
366 r['created'] = datetime_from_rietveld(reply['date'])
367 r['content'] = ''
368 ret.append(r)
369 return ret
370
Vadim Bendebury8de38002018-05-14 19:02:55 -0700371 def gerrit_changes_over_rest(self, instance, filters):
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200372 # Convert the "key:value" filter to a list of (key, value) pairs.
373 req = list(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000374 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000375 # Instantiate the generator to force all the requests now and catch the
376 # errors here.
377 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000378 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS',
379 'CURRENT_REVISION', 'CURRENT_COMMIT']))
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000380 except gerrit_util.GerritError, e:
Vadim Bendebury8de38002018-05-14 19:02:55 -0700381 error_message = 'Looking up %r: %s' % (instance['url'], e)
382 if error_message not in self.access_errors:
383 self.access_errors.add(error_message)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000384 return []
385
deymo@chromium.org6c039202013-09-12 12:28:12 +0000386 def gerrit_search(self, instance, owner=None, reviewer=None):
387 max_age = datetime.today() - self.modified_after
Andrii Shyshkalov4aedec02018-09-17 16:31:17 +0000388 filters = ['-age:%ss' % (max_age.days * 24 * 3600 + max_age.seconds)]
389 if owner:
390 assert not reviewer
391 filters.append('owner:%s' % owner)
392 else:
393 filters.extend(('-owner:%s' % reviewer, 'reviewer:%s' % reviewer))
Peter K. Lee9342ac02018-08-28 21:03:51 +0000394 # TODO(cjhopman): Should abandoned changes be filtered out when
395 # merged_only is not enabled?
396 if self.options.merged_only:
397 filters.append('status:merged')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000398
Aaron Gable2979a872017-09-05 17:38:32 -0700399 issues = self.gerrit_changes_over_rest(instance, filters)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100400 self.show_progress()
Aaron Gable2979a872017-09-05 17:38:32 -0700401 issues = [self.process_gerrit_issue(instance, issue)
402 for issue in issues]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000403
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000404 issues = filter(self.filter_issue, issues)
405 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
406
407 return issues
408
Aaron Gable2979a872017-09-05 17:38:32 -0700409 def process_gerrit_issue(self, instance, issue):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000410 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000411 if self.options.deltas:
412 ret['delta'] = DefaultFormatter().format(
413 '+{insertions},-{deletions}',
414 **issue)
415 ret['status'] = issue['status']
deymo@chromium.org6c039202013-09-12 12:28:12 +0000416 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700417 protocol = instance.get('short_url_protocol', 'http')
418 url = instance['shorturl']
419 else:
420 protocol = 'https'
421 url = instance['url']
422 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
423
deymo@chromium.org6c039202013-09-12 12:28:12 +0000424 ret['header'] = issue['subject']
Don Garrett2ebf9fd2018-08-06 15:14:00 +0000425 ret['owner'] = issue['owner'].get('email', '')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000426 ret['author'] = ret['owner']
427 ret['created'] = datetime_from_gerrit(issue['created'])
428 ret['modified'] = datetime_from_gerrit(issue['updated'])
429 if 'messages' in issue:
Aaron Gable2979a872017-09-05 17:38:32 -0700430 ret['replies'] = self.process_gerrit_issue_replies(issue['messages'])
deymo@chromium.org6c039202013-09-12 12:28:12 +0000431 else:
432 ret['replies'] = []
433 ret['reviewers'] = set(r['author'] for r in ret['replies'])
434 ret['reviewers'].discard(ret['author'])
Andrii Shyshkalov024a3312018-06-29 21:59:06 +0000435 ret['bugs'] = self.extract_bug_numbers_from_description(issue)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000436 return ret
437
438 @staticmethod
Aaron Gable2979a872017-09-05 17:38:32 -0700439 def process_gerrit_issue_replies(replies):
deymo@chromium.org6c039202013-09-12 12:28:12 +0000440 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000441 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
442 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000443 for reply in replies:
444 ret.append({
445 'author': reply['author']['email'],
446 'created': datetime_from_gerrit(reply['date']),
447 'content': reply['message'],
448 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000449 return ret
450
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100451 def monorail_get_auth_http(self):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000452 auth_config = auth.extract_auth_config_from_options(self.options)
453 authenticator = auth.get_authenticator_for_host(
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000454 'bugs.chromium.org', auth_config)
Kenneth Russell66badbd2018-09-09 21:35:32 +0000455 # Manually use a long timeout (10m); for some users who have a
456 # long history on the issue tracker, whatever the default timeout
457 # is is reached.
458 return authenticator.authorize(httplib2.Http(timeout=600))
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100459
460 def filter_modified_monorail_issue(self, issue):
461 """Precisely checks if an issue has been modified in the time range.
462
463 This fetches all issue comments to check if the issue has been modified in
464 the time range specified by user. This is needed because monorail only
465 allows filtering by last updated and published dates, which is not
466 sufficient to tell whether a given issue has been modified at some specific
467 time range. Any update to the issue is a reported as comment on Monorail.
468
469 Args:
470 issue: Issue dict as returned by monorail_query_issues method. In
471 particular, must have a key 'uid' formatted as 'project:issue_id'.
472
473 Returns:
474 Passed issue if modified, None otherwise.
475 """
476 http = self.monorail_get_auth_http()
477 project, issue_id = issue['uid'].split(':')
478 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
479 '/%s/issues/%s/comments?maxResults=10000') % (project, issue_id)
480 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100481 self.show_progress()
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100482 content = json.loads(body)
483 if not content:
484 logging.error('Unable to parse %s response from monorail.', project)
485 return issue
486
487 for item in content.get('items', []):
488 comment_published = datetime_from_monorail(item['published'])
489 if self.filter_modified(comment_published):
490 return issue
491
492 return None
493
494 def monorail_query_issues(self, project, query):
495 http = self.monorail_get_auth_http()
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000496 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100497 '/%s/issues') % project
498 query_data = urllib.urlencode(query)
499 url = url + '?' + query_data
500 _, body = http.request(url)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100501 self.show_progress()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100502 content = json.loads(body)
503 if not content:
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100504 logging.error('Unable to parse %s response from monorail.', project)
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100505 return []
506
507 issues = []
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100508 project_config = monorail_projects.get(project, {})
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100509 for item in content.get('items', []):
510 if project_config.get('shorturl'):
511 protocol = project_config.get('short_url_protocol', 'http')
512 item_url = '%s://%s/%d' % (
513 protocol, project_config['shorturl'], item['id'])
514 else:
515 item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
516 project, item['id'])
517 issue = {
518 'uid': '%s:%s' % (project, item['id']),
519 'header': item['title'],
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100520 'created': datetime_from_monorail(item['published']),
521 'modified': datetime_from_monorail(item['updated']),
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100522 'author': item['author']['name'],
523 'url': item_url,
524 'comments': [],
525 'status': item['status'],
526 'labels': [],
527 'components': []
528 }
529 if 'owner' in item:
530 issue['owner'] = item['owner']['name']
531 else:
532 issue['owner'] = 'None'
533 if 'labels' in item:
534 issue['labels'] = item['labels']
535 if 'components' in item:
536 issue['components'] = item['components']
537 issues.append(issue)
538
539 return issues
540
541 def monorail_issue_search(self, project):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000542 epoch = datetime.utcfromtimestamp(0)
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +0000543 # TODO(tandrii): support non-chromium email, too.
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000544 user_str = '%s@chromium.org' % self.user
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000545
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100546 issues = self.monorail_query_issues(project, {
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000547 'maxResults': 10000,
548 'q': user_str,
549 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
550 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000551 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000552
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +0000553 if self.options.completed_issues:
554 return [
555 issue for issue in issues
556 if (self.match(issue['owner']) and
557 issue['status'].lower() in ('verified', 'fixed'))
558 ]
559
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100560 return [
561 issue for issue in issues
562 if issue['author'] == user_str or issue['owner'] == user_str]
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000563
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100564 def monorail_get_issues(self, project, issue_ids):
565 return self.monorail_query_issues(project, {
566 'maxResults': 10000,
567 'q': 'id:%s' % ','.join(issue_ids)
568 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000569
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000570 def print_heading(self, heading):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000571 print()
572 print(self.options.output_format_heading.format(heading=heading))
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000573
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000574 def match(self, author):
575 if '@' in self.user:
576 return author == self.user
577 return author.startswith(self.user + '@')
578
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000579 def print_change(self, change):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000580 activity = len([
581 reply
582 for reply in change['replies']
583 if self.match(reply['author'])
584 ])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000585 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000586 'created': change['created'].date().isoformat(),
587 'modified': change['modified'].date().isoformat(),
588 'reviewers': ', '.join(change['reviewers']),
589 'status': change['status'],
590 'activity': activity,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000591 }
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000592 if self.options.deltas:
593 optional_values['delta'] = change['delta']
594
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000595 self.print_generic(self.options.output_format,
596 self.options.output_format_changes,
597 change['header'],
598 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000599 change['author'],
Lutz Justen860378e2019-03-12 01:10:02 +0000600 change['created'],
601 change['modified'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000602 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000603
604 def print_issue(self, issue):
605 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000606 'created': issue['created'].date().isoformat(),
607 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000608 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000609 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000610 }
611 self.print_generic(self.options.output_format,
612 self.options.output_format_issues,
613 issue['header'],
614 issue['url'],
615 issue['author'],
Lutz Justen860378e2019-03-12 01:10:02 +0000616 issue['created'],
617 issue['modified'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000618 optional_values)
619
620 def print_review(self, review):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000621 activity = len([
622 reply
623 for reply in review['replies']
624 if self.match(reply['author'])
625 ])
626 optional_values = {
627 'created': review['created'].date().isoformat(),
628 'modified': review['modified'].date().isoformat(),
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800629 'status': review['status'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000630 'activity': activity,
631 }
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800632 if self.options.deltas:
633 optional_values['delta'] = review['delta']
634
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000635 self.print_generic(self.options.output_format,
636 self.options.output_format_reviews,
637 review['header'],
638 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000639 review['author'],
Lutz Justen860378e2019-03-12 01:10:02 +0000640 review['created'],
641 review['modified'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000642 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000643
644 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000645 def print_generic(default_fmt, specific_fmt,
Lutz Justen860378e2019-03-12 01:10:02 +0000646 title, url, author, created, modified,
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000647 optional_values=None):
648 output_format = specific_fmt if specific_fmt is not None else default_fmt
649 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000650 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000651 'title': title,
652 'url': url,
653 'author': author,
Lutz Justen860378e2019-03-12 01:10:02 +0000654 'created': created,
655 'modified': modified,
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000656 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000657 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000658 values.update(optional_values)
Raul Tambre80ee78e2019-05-06 22:41:05 +0000659 print(DefaultFormatter().format(output_format,
660 **values).encode(sys.getdefaultencoding()))
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000661
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000662
663 def filter_issue(self, issue, should_filter_by_user=True):
664 def maybe_filter_username(email):
665 return not should_filter_by_user or username(email) == self.user
666 if (maybe_filter_username(issue['author']) and
667 self.filter_modified(issue['created'])):
668 return True
669 if (maybe_filter_username(issue['owner']) and
670 (self.filter_modified(issue['created']) or
671 self.filter_modified(issue['modified']))):
672 return True
673 for reply in issue['replies']:
674 if self.filter_modified(reply['created']):
675 if not should_filter_by_user:
676 break
677 if (username(reply['author']) == self.user
678 or (self.user + '@') in reply['content']):
679 break
680 else:
681 return False
682 return True
683
684 def filter_modified(self, modified):
685 return self.modified_after < modified and modified < self.modified_before
686
687 def auth_for_changes(self):
688 #TODO(cjhopman): Move authentication check for getting changes here.
689 pass
690
691 def auth_for_reviews(self):
692 # Reviews use all the same instances as changes so no authentication is
693 # required.
694 pass
695
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000696 def get_changes(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100697 num_instances = len(rietveld_instances) + len(gerrit_instances)
698 with contextlib.closing(ThreadPool(num_instances)) as pool:
699 rietveld_changes = pool.map_async(
700 lambda instance: self.rietveld_search(instance, owner=self.user),
701 rietveld_instances)
702 gerrit_changes = pool.map_async(
703 lambda instance: self.gerrit_search(instance, owner=self.user),
704 gerrit_instances)
705 rietveld_changes = itertools.chain.from_iterable(rietveld_changes.get())
706 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
707 self.changes = list(rietveld_changes) + list(gerrit_changes)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000708
709 def print_changes(self):
710 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000711 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000712 for change in self.changes:
Bruce Dawson2afcf222019-02-25 22:07:08 +0000713 self.print_change(change)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000714
Vadim Bendebury8de38002018-05-14 19:02:55 -0700715 def print_access_errors(self):
716 if self.access_errors:
Ryan Harrison398fb442018-05-22 12:05:26 -0400717 logging.error('Access Errors:')
718 for error in self.access_errors:
719 logging.error(error.rstrip())
Vadim Bendebury8de38002018-05-14 19:02:55 -0700720
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000721 def get_reviews(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100722 num_instances = len(rietveld_instances) + len(gerrit_instances)
723 with contextlib.closing(ThreadPool(num_instances)) as pool:
724 rietveld_reviews = pool.map_async(
725 lambda instance: self.rietveld_search(instance, reviewer=self.user),
726 rietveld_instances)
727 gerrit_reviews = pool.map_async(
728 lambda instance: self.gerrit_search(instance, reviewer=self.user),
729 gerrit_instances)
730 rietveld_reviews = itertools.chain.from_iterable(rietveld_reviews.get())
731 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100732 self.reviews = list(rietveld_reviews) + list(gerrit_reviews)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000733
734 def print_reviews(self):
735 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000736 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000737 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000738 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000739
740 def get_issues(self):
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100741 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
742 monorail_issues = pool.map(
743 self.monorail_issue_search, monorail_projects.keys())
744 monorail_issues = list(itertools.chain.from_iterable(monorail_issues))
745
Vadim Bendeburycbf02042018-05-14 17:46:15 -0700746 if not monorail_issues:
747 return
748
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100749 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
750 filtered_issues = pool.map(
751 self.filter_modified_monorail_issue, monorail_issues)
752 self.issues = [issue for issue in filtered_issues if issue]
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100753
754 def get_referenced_issues(self):
755 if not self.issues:
756 self.get_issues()
757
758 if not self.changes:
759 self.get_changes()
760
761 referenced_issue_uids = set(itertools.chain.from_iterable(
762 change['bugs'] for change in self.changes))
763 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
764 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
765
766 missing_issues_by_project = collections.defaultdict(list)
767 for issue_uid in missing_issue_uids:
768 project, issue_id = issue_uid.split(':')
769 missing_issues_by_project[project].append(issue_id)
770
771 for project, issue_ids in missing_issues_by_project.iteritems():
772 self.referenced_issues += self.monorail_get_issues(project, issue_ids)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000773
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000774 def print_issues(self):
775 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000776 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000777 for issue in self.issues:
778 self.print_issue(issue)
779
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100780 def print_changes_by_issue(self, skip_empty_own):
781 if not self.issues or not self.changes:
782 return
783
784 self.print_heading('Changes by referenced issue(s)')
785 issues = {issue['uid']: issue for issue in self.issues}
786 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
787 changes_by_issue_uid = collections.defaultdict(list)
788 changes_by_ref_issue_uid = collections.defaultdict(list)
789 changes_without_issue = []
790 for change in self.changes:
791 added = False
Andrii Shyshkalov483580b2018-07-02 06:55:10 +0000792 for issue_uid in change['bugs']:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100793 if issue_uid in issues:
794 changes_by_issue_uid[issue_uid].append(change)
795 added = True
796 if issue_uid in ref_issues:
797 changes_by_ref_issue_uid[issue_uid].append(change)
798 added = True
799 if not added:
800 changes_without_issue.append(change)
801
802 # Changes referencing own issues.
803 for issue_uid in issues:
804 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
805 self.print_issue(issues[issue_uid])
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +0000806 if changes_by_issue_uid[issue_uid]:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000807 print()
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +0000808 for change in changes_by_issue_uid[issue_uid]:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000809 print(' ', end='') # this prints no newline
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +0000810 self.print_change(change)
Raul Tambre80ee78e2019-05-06 22:41:05 +0000811 print()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100812
813 # Changes referencing others' issues.
814 for issue_uid in ref_issues:
815 assert changes_by_ref_issue_uid[issue_uid]
816 self.print_issue(ref_issues[issue_uid])
817 for change in changes_by_ref_issue_uid[issue_uid]:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000818 print('', end=' ') # this prints one space due to comma, but no newline
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100819 self.print_change(change)
820
821 # Changes referencing no issues.
822 if changes_without_issue:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000823 print(self.options.output_format_no_url.format(title='Other changes'))
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100824 for change in changes_without_issue:
Raul Tambre80ee78e2019-05-06 22:41:05 +0000825 print('', end=' ') # this prints one space due to comma, but no newline
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100826 self.print_change(change)
827
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000828 def print_activity(self):
829 self.print_changes()
830 self.print_reviews()
831 self.print_issues()
832
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000833 def dump_json(self, ignore_keys=None):
834 if ignore_keys is None:
835 ignore_keys = ['replies']
836
837 def format_for_json_dump(in_array):
838 output = {}
839 for item in in_array:
840 url = item.get('url') or item.get('review_url')
841 if not url:
842 raise Exception('Dumped item %s does not specify url' % item)
843 output[url] = dict(
844 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
845 return output
846
847 class PythonObjectEncoder(json.JSONEncoder):
848 def default(self, obj): # pylint: disable=method-hidden
849 if isinstance(obj, datetime):
850 return obj.isoformat()
851 if isinstance(obj, set):
852 return list(obj)
853 return json.JSONEncoder.default(self, obj)
854
855 output = {
856 'reviews': format_for_json_dump(self.reviews),
857 'changes': format_for_json_dump(self.changes),
858 'issues': format_for_json_dump(self.issues)
859 }
Raul Tambre80ee78e2019-05-06 22:41:05 +0000860 print(json.dumps(output, indent=2, cls=PythonObjectEncoder))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000861
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000862
863def main():
864 # Silence upload.py.
865 rietveld.upload.verbosity = 0
866
867 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
868 parser.add_option(
869 '-u', '--user', metavar='<email>',
Bruce Dawson84370962019-02-21 05:33:37 +0000870 # Look for USER and USERNAME (Windows) environment variables.
871 default=os.environ.get('USER', os.environ.get('USERNAME')),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000872 help='Filter on user, default=%default')
873 parser.add_option(
874 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000875 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000876 parser.add_option(
877 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000878 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000879 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
880 relativedelta(months=2))
881 parser.add_option(
882 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000883 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000884 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
885 parser.add_option(
886 '-Y', '--this_year', action='store_true',
887 help='Use this year\'s dates')
888 parser.add_option(
889 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000890 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000891 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000892 '-W', '--last_week', action='count',
893 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000894 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000895 '-a', '--auth',
896 action='store_true',
897 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000898 parser.add_option(
899 '-d', '--deltas',
900 action='store_true',
Nicolas Boichat23c165f2018-01-26 10:04:27 +0800901 help='Fetch deltas for changes.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100902 parser.add_option(
903 '--no-referenced-issues',
904 action='store_true',
905 help='Do not fetch issues referenced by owned changes. Useful in '
906 'combination with --changes-by-issue when you only want to list '
Sergiy Byelozyorovb4475ab2018-03-23 17:49:34 +0100907 'issues that have also been modified in the same time period.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100908 parser.add_option(
909 '--skip-own-issues-without-changes',
910 action='store_true',
911 help='Skips listing own issues without changes when showing changes '
912 'grouped by referenced issue(s). See --changes-by-issue for more '
913 'details.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000914
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000915 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000916 'By default, all activity will be looked up and '
917 'printed. If any of these are specified, only '
918 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000919 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000920 '-c', '--changes',
921 action='store_true',
922 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000923 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000924 '-i', '--issues',
925 action='store_true',
926 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000927 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000928 '-r', '--reviews',
929 action='store_true',
930 help='Show reviews.')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100931 activity_types_group.add_option(
932 '--changes-by-issue', action='store_true',
933 help='Show changes grouped by referenced issue(s).')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000934 parser.add_option_group(activity_types_group)
935
936 output_format_group = optparse.OptionGroup(parser, 'Output Format',
937 'By default, all activity will be printed in the '
938 'following format: {url} {title}. This can be '
939 'changed for either all activity types or '
940 'individually for each activity type. The format '
941 'is defined as documented for '
942 'string.format(...). The variables available for '
Lutz Justen860378e2019-03-12 01:10:02 +0000943 'all activity types are url, title, author, '
944 'created and modified. Format options for '
945 'specific activity types will override the '
946 'generic format.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000947 output_format_group.add_option(
948 '-f', '--output-format', metavar='<format>',
949 default=u'{url} {title}',
950 help='Specifies the format to use when printing all your activity.')
951 output_format_group.add_option(
952 '--output-format-changes', metavar='<format>',
953 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000954 help='Specifies the format to use when printing changes. Supports the '
955 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000956 output_format_group.add_option(
957 '--output-format-issues', metavar='<format>',
958 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000959 help='Specifies the format to use when printing issues. Supports the '
960 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000961 output_format_group.add_option(
962 '--output-format-reviews', metavar='<format>',
963 default=None,
964 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000965 output_format_group.add_option(
966 '--output-format-heading', metavar='<format>',
967 default=u'{heading}:',
968 help='Specifies the format to use when printing headings.')
969 output_format_group.add_option(
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100970 '--output-format-no-url', default='{title}',
971 help='Specifies the format to use when printing activity without url.')
972 output_format_group.add_option(
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000973 '-m', '--markdown', action='store_true',
974 help='Use markdown-friendly output (overrides --output-format '
975 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000976 output_format_group.add_option(
977 '-j', '--json', action='store_true',
978 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000979 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000980 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000981
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000982 parser.add_option(
983 '-v', '--verbose',
984 action='store_const',
985 dest='verbosity',
986 default=logging.WARN,
987 const=logging.INFO,
988 help='Output extra informational messages.'
989 )
990 parser.add_option(
991 '-q', '--quiet',
992 action='store_const',
993 dest='verbosity',
994 const=logging.ERROR,
995 help='Suppress non-error messages.'
996 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000997 parser.add_option(
Peter K. Lee9342ac02018-08-28 21:03:51 +0000998 '-M', '--merged-only',
999 action='store_true',
1000 dest='merged_only',
1001 default=False,
1002 help='Shows only changes that have been merged.')
1003 parser.add_option(
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +00001004 '-C', '--completed-issues',
1005 action='store_true',
1006 dest='completed_issues',
1007 default=False,
1008 help='Shows only monorail issues that have completed (Fixed|Verified) '
1009 'by the user.')
1010 parser.add_option(
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001011 '-o', '--output', metavar='<file>',
1012 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001013
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001014 # Remove description formatting
1015 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -08001016 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001017
1018 options, args = parser.parse_args()
1019 options.local_user = os.environ.get('USER')
1020 if args:
1021 parser.error('Args unsupported')
1022 if not options.user:
Bruce Dawson84370962019-02-21 05:33:37 +00001023 parser.error('USER/USERNAME is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001024 options.user = username(options.user)
1025
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001026 logging.basicConfig(level=options.verbosity)
1027
1028 # python-keyring provides easy access to the system keyring.
1029 try:
1030 import keyring # pylint: disable=unused-import,unused-variable,F0401
1031 except ImportError:
1032 logging.warning('Consider installing python-keyring')
1033
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001034 if not options.begin:
1035 if options.last_quarter:
1036 begin, end = quarter_begin, quarter_end
1037 elif options.this_year:
1038 begin, end = get_year_of(datetime.today())
1039 elif options.week_of:
1040 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +00001041 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +00001042 begin, end = (get_week_of(datetime.today() -
1043 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001044 else:
1045 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
1046 else:
Daniel Cheng4b37ce62017-09-07 12:00:02 -07001047 begin = dateutil.parser.parse(options.begin)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001048 if options.end:
Daniel Cheng4b37ce62017-09-07 12:00:02 -07001049 end = dateutil.parser.parse(options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001050 else:
1051 end = datetime.today()
1052 options.begin, options.end = begin, end
Bruce Dawson2afcf222019-02-25 22:07:08 +00001053 if begin >= end:
1054 # The queries fail in peculiar ways when the begin date is in the future.
1055 # Give a descriptive error message instead.
1056 logging.error('Start date (%s) is the same or later than end date (%s)' %
1057 (begin, end))
1058 return 1
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001059
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +00001060 if options.markdown:
Andrii Shyshkalovd4c2a872018-06-29 21:23:46 +00001061 options.output_format_heading = '### {heading}\n'
1062 options.output_format = ' * [{title}]({url})'
1063 options.output_format_no_url = ' * {title}'
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001064 logging.info('Searching for activity by %s', options.user)
1065 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001066
1067 my_activity = MyActivity(options)
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001068 my_activity.show_progress('Loading data')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001069
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001070 if not (options.changes or options.reviews or options.issues or
1071 options.changes_by_issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001072 options.changes = True
1073 options.issues = True
1074 options.reviews = True
1075
1076 # First do any required authentication so none of the user interaction has to
1077 # wait for actual work.
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001078 if options.changes or options.changes_by_issue:
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001079 my_activity.auth_for_changes()
1080 if options.reviews:
1081 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001082
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001083 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001084
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001085 try:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001086 if options.changes or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001087 my_activity.get_changes()
1088 if options.reviews:
1089 my_activity.get_reviews()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001090 if options.issues or options.changes_by_issue:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001091 my_activity.get_issues()
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001092 if not options.no_referenced_issues:
1093 my_activity.get_referenced_issues()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +00001094 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +00001095 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001096
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +01001097 my_activity.show_progress('\n')
1098
Vadim Bendebury8de38002018-05-14 19:02:55 -07001099 my_activity.print_access_errors()
1100
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001101 output_file = None
1102 try:
1103 if options.output:
1104 output_file = open(options.output, 'w')
1105 logging.info('Printing output to "%s"', options.output)
1106 sys.stdout = output_file
1107 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -07001108 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001109 else:
1110 if options.json:
1111 my_activity.dump_json()
1112 else:
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +01001113 if options.changes:
1114 my_activity.print_changes()
1115 if options.reviews:
1116 my_activity.print_reviews()
1117 if options.issues:
1118 my_activity.print_issues()
1119 if options.changes_by_issue:
1120 my_activity.print_changes_by_issue(
1121 options.skip_own_issues_without_changes)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +00001122 finally:
1123 if output_file:
1124 logging.info('Done printing to file.')
1125 sys.stdout = sys.__stdout__
1126 output_file.close()
1127
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001128 return 0
1129
1130
1131if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001132 # Fix encoding to support non-ascii issue titles.
1133 fix_encoding.fix_encoding()
1134
sbc@chromium.org013731e2015-02-26 18:28:43 +00001135 try:
1136 sys.exit(main())
1137 except KeyboardInterrupt:
1138 sys.stderr.write('interrupted\n')
1139 sys.exit(1)