blob: b1824dba6738c20f1441d8a22812c81f4bf1b84b [file] [log] [blame]
Edward Lemura3b6fd02020-03-02 22:16:15 +00001#!/usr/bin/env vpython3
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.
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00005"""Get stats about your activity.
6
7Example:
8 - my_activity.py for stats for the current week (last week on mondays).
9 - my_activity.py -Q for stats for last quarter.
10 - my_activity.py -Y for stats for this year.
Mohamed Heikaldc37feb2019-06-28 22:33:58 +000011 - my_activity.py -b 4/24/19 for stats since April 24th 2019.
12 - my_activity.py -b 4/24/19 -e 6/16/19 stats between April 24th and June 16th.
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000013 - my_activity.py -jd to output stats for the week to json with deltas data.
Nicolas Boichatcd3696c2021-06-02 01:42:18 +000014
15To add additional gerrit instances, one can pass a JSON file as parameter:
16 - my_activity.py -F config.json
17{
18 "gerrit_instances": {
19 "team-internal-review.googlesource.com": {
20 "shorturl": "go/teamcl",
21 "short_url_protocol": "http"
22 },
23 "team-external-review.googlesource.com": {}
24 }
25}
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000026"""
27
28# These services typically only provide a created time and a last modified time
29# for each item for general queries. This is not enough to determine if there
30# was activity in a given time period. So, we first query for all things created
31# before end and modified after begin. Then, we get the details of each item and
32# check those details to determine if there was activity in the given period.
33# This means that query time scales mostly with (today() - begin).
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
Edward Lemur202c5592019-10-21 22:44:52 +000041import httplib2
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
Tobias Sargeantffb3c432017-03-08 14:09:14 +000048from string import Formatter
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000049import sys
50import urllib
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000051import re
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000052
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000053import auth
stevefung@chromium.org832d51e2015-05-27 12:52:51 +000054import fix_encoding
Edward Lesmesae3586b2020-03-23 21:21:14 +000055import gclient_utils
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000056import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000057
Edward Lemur2a048032020-01-14 22:58:13 +000058if sys.version_info.major == 2:
Mike Frysinger124bb8e2023-09-06 05:48:55 +000059 logging.critical(
60 'Python 2 is not supported. Run my_activity.py using vpython3.')
Edward Lemur2a048032020-01-14 22:58:13 +000061
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000062try:
Mike Frysinger124bb8e2023-09-06 05:48:55 +000063 import dateutil # pylint: disable=import-error
64 import dateutil.parser
65 from dateutil.relativedelta import relativedelta
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000066except ImportError:
Mike Frysinger124bb8e2023-09-06 05:48:55 +000067 logging.error('python-dateutil package required')
68 sys.exit(1)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000069
Tobias Sargeantffb3c432017-03-08 14:09:14 +000070
71class DefaultFormatter(Formatter):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000072 def __init__(self, default=''):
73 super(DefaultFormatter, self).__init__()
74 self.default = default
Tobias Sargeantffb3c432017-03-08 14:09:14 +000075
Mike Frysinger124bb8e2023-09-06 05:48:55 +000076 def get_value(self, key, args, kwargs):
77 if isinstance(key, str) and key not in kwargs:
78 return self.default
79 return Formatter.get_value(self, key, args, kwargs)
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +000080
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000081
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000082gerrit_instances = [
Mike Frysinger124bb8e2023-09-06 05:48:55 +000083 {
84 'url': 'android-review.googlesource.com',
85 'shorturl': 'r.android.com',
86 'short_url_protocol': 'https',
87 },
88 {
89 'url': 'gerrit-review.googlesource.com',
90 },
91 {
92 'url': 'chrome-internal-review.googlesource.com',
93 'shorturl': 'crrev.com/i',
94 'short_url_protocol': 'https',
95 },
96 {
97 'url': 'chromium-review.googlesource.com',
98 'shorturl': 'crrev.com/c',
99 'short_url_protocol': 'https',
100 },
101 {
102 'url': 'dawn-review.googlesource.com',
103 },
104 {
105 'url': 'pdfium-review.googlesource.com',
106 },
107 {
108 'url': 'skia-review.googlesource.com',
109 },
110 {
111 'url': 'review.coreboot.org',
112 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000113]
114
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100115monorail_projects = {
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000116 'angleproject': {
117 'shorturl': 'anglebug.com',
118 'short_url_protocol': 'http',
119 },
120 'chromium': {
121 'shorturl': 'crbug.com',
122 'short_url_protocol': 'https',
123 },
124 'dawn': {},
125 'google-breakpad': {},
126 'gyp': {},
127 'pdfium': {
128 'shorturl': 'crbug.com/pdfium',
129 'short_url_protocol': 'https',
130 },
131 'skia': {},
132 'tint': {},
133 'v8': {
134 'shorturl': 'crbug.com/v8',
135 'short_url_protocol': 'https',
136 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100137}
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000138
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000139
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000140def username(email):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000141 """Keeps the username of an email address."""
142 return email and email.split('@', 1)[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000143
144
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000145def datetime_to_midnight(date):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000146 return date - timedelta(hours=date.hour,
147 minutes=date.minute,
148 seconds=date.second,
149 microseconds=date.microsecond)
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000150
151
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000152def get_quarter_of(date):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000153 begin = (datetime_to_midnight(date) -
154 relativedelta(months=(date.month - 1) % 3, days=(date.day - 1)))
155 return begin, begin + relativedelta(months=3)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000156
157
158def get_year_of(date):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000159 begin = (datetime_to_midnight(date) -
160 relativedelta(months=(date.month - 1), days=(date.day - 1)))
161 return begin, begin + relativedelta(years=1)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000162
163
164def get_week_of(date):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000165 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
166 return begin, begin + timedelta(days=7)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000167
168
169def get_yes_or_no(msg):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000170 while True:
171 response = gclient_utils.AskForData(msg + ' yes/no [no] ')
172 if response in ('y', 'yes'):
173 return True
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +0000174
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000175 if not response or response in ('n', 'no'):
176 return False
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000177
178
deymo@chromium.org6c039202013-09-12 12:28:12 +0000179def datetime_from_gerrit(date_string):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000180 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000181
182
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100183def datetime_from_monorail(date_string):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000184 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S')
185
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000186
Edward Lemurab9cc2c2020-02-25 23:26:24 +0000187def extract_bug_numbers_from_description(issue):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000188 # Getting the description for REST Gerrit
189 revision = issue['revisions'][issue['current_revision']]
190 description = revision['commit']['message']
Edward Lemurab9cc2c2020-02-25 23:26:24 +0000191
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000192 bugs = []
193 # Handle both "Bug: 99999" and "BUG=99999" bug notations
194 # Multiple bugs can be noted on a single line or in multiple ones.
195 matches = re.findall(
196 r'^(BUG=|(Bug|Fixed):\s*)((((?:[a-zA-Z0-9-]+:)?\d+)(,\s?)?)+)',
197 description,
198 flags=re.IGNORECASE | re.MULTILINE)
199 if matches:
200 for match in matches:
201 bugs.extend(match[2].replace(' ', '').split(','))
202 # Add default chromium: prefix if none specified.
203 bugs = [bug if ':' in bug else 'chromium:%s' % bug for bug in bugs]
Edward Lemurab9cc2c2020-02-25 23:26:24 +0000204
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000205 return sorted(set(bugs))
206
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000207
208class MyActivity(object):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000209 def __init__(self, options):
210 self.options = options
211 self.modified_after = options.begin
212 self.modified_before = options.end
213 self.user = options.user
214 self.changes = []
215 self.reviews = []
216 self.issues = []
217 self.referenced_issues = []
218 self.google_code_auth_token = None
219 self.access_errors = set()
220 self.skip_servers = (options.skip_servers.split(','))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000221
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000222 def show_progress(self, how='.'):
223 if sys.stdout.isatty():
224 sys.stdout.write(how)
225 sys.stdout.flush()
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100226
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000227 def gerrit_changes_over_rest(self, instance, filters):
228 # Convert the "key:value" filter to a list of (key, value) pairs.
229 req = list(f.split(':', 1) for f in filters)
230 try:
231 # Instantiate the generator to force all the requests now and catch
232 # the errors here.
233 return list(
234 gerrit_util.GenerateAllChanges(instance['url'],
235 req,
236 o_params=[
237 'MESSAGES', 'LABELS',
238 'DETAILED_ACCOUNTS',
239 'CURRENT_REVISION',
240 'CURRENT_COMMIT'
241 ]))
242 except gerrit_util.GerritError as e:
243 error_message = 'Looking up %r: %s' % (instance['url'], e)
244 if error_message not in self.access_errors:
245 self.access_errors.add(error_message)
246 return []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000247
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000248 def gerrit_search(self, instance, owner=None, reviewer=None):
249 if instance['url'] in self.skip_servers:
250 return []
251 max_age = datetime.today() - self.modified_after
252 filters = ['-age:%ss' % (max_age.days * 24 * 3600 + max_age.seconds)]
253 if owner:
254 assert not reviewer
255 filters.append('owner:%s' % owner)
256 else:
257 filters.extend(('-owner:%s' % reviewer, 'reviewer:%s' % reviewer))
258 # TODO(cjhopman): Should abandoned changes be filtered out when
259 # merged_only is not enabled?
260 if self.options.merged_only:
261 filters.append('status:merged')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000262
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000263 issues = self.gerrit_changes_over_rest(instance, filters)
264 self.show_progress()
265 issues = [
266 self.process_gerrit_issue(instance, issue) for issue in issues
267 ]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000268
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000269 issues = filter(self.filter_issue, issues)
270 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000271
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000272 return issues
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000273
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000274 def process_gerrit_issue(self, instance, issue):
275 ret = {}
276 if self.options.deltas:
277 ret['delta'] = DefaultFormatter().format(
278 '+{insertions},-{deletions}', **issue)
279 ret['status'] = issue['status']
280 if 'shorturl' in instance:
281 protocol = instance.get('short_url_protocol', 'http')
282 url = instance['shorturl']
283 else:
284 protocol = 'https'
285 url = instance['url']
286 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700287
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000288 ret['header'] = issue['subject']
289 ret['owner'] = issue['owner'].get('email', '')
290 ret['author'] = ret['owner']
291 ret['created'] = datetime_from_gerrit(issue['created'])
292 ret['modified'] = datetime_from_gerrit(issue['updated'])
293 if 'messages' in issue:
294 ret['replies'] = self.process_gerrit_issue_replies(
295 issue['messages'])
296 else:
297 ret['replies'] = []
298 ret['reviewers'] = set(r['author'] for r in ret['replies'])
299 ret['reviewers'].discard(ret['author'])
300 ret['bugs'] = extract_bug_numbers_from_description(issue)
301 return ret
deymo@chromium.org6c039202013-09-12 12:28:12 +0000302
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000303 @staticmethod
304 def process_gerrit_issue_replies(replies):
305 ret = []
306 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
307 replies)
308 for reply in replies:
309 ret.append({
310 'author': reply['author']['email'],
311 'created': datetime_from_gerrit(reply['date']),
312 'content': reply['message'],
313 })
314 return ret
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000315
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000316 def monorail_get_auth_http(self):
317 # Manually use a long timeout (10m); for some users who have a
318 # long history on the issue tracker, whatever the default timeout
319 # is is reached.
320 return auth.Authenticator().authorize(httplib2.Http(timeout=600))
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100321
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000322 def filter_modified_monorail_issue(self, issue):
323 """Precisely checks if an issue has been modified in the time range.
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100324
325 This fetches all issue comments to check if the issue has been modified in
326 the time range specified by user. This is needed because monorail only
327 allows filtering by last updated and published dates, which is not
328 sufficient to tell whether a given issue has been modified at some specific
329 time range. Any update to the issue is a reported as comment on Monorail.
330
331 Args:
332 issue: Issue dict as returned by monorail_query_issues method. In
333 particular, must have a key 'uid' formatted as 'project:issue_id'.
334
335 Returns:
336 Passed issue if modified, None otherwise.
337 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000338 http = self.monorail_get_auth_http()
339 project, issue_id = issue['uid'].split(':')
340 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
341 '/%s/issues/%s/comments?maxResults=10000') % (project, issue_id)
342 _, body = http.request(url)
343 self.show_progress()
344 content = json.loads(body)
345 if not content:
346 logging.error('Unable to parse %s response from monorail.', project)
347 return issue
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100348
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000349 for item in content.get('items', []):
350 comment_published = datetime_from_monorail(item['published'])
351 if self.filter_modified(comment_published):
352 return issue
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100353
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000354 return None
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100355
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000356 def monorail_query_issues(self, project, query):
357 http = self.monorail_get_auth_http()
358 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
359 '/%s/issues') % project
360 query_data = urllib.parse.urlencode(query)
361 url = url + '?' + query_data
362 _, body = http.request(url)
363 self.show_progress()
364 content = json.loads(body)
365 if not content:
366 logging.error('Unable to parse %s response from monorail.', project)
367 return []
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100368
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000369 issues = []
370 project_config = monorail_projects.get(project, {})
371 for item in content.get('items', []):
372 if project_config.get('shorturl'):
373 protocol = project_config.get('short_url_protocol', 'http')
374 item_url = '%s://%s/%d' % (protocol, project_config['shorturl'],
375 item['id'])
376 else:
377 item_url = (
378 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' %
379 (project, item['id']))
380 issue = {
381 'uid': '%s:%s' % (project, item['id']),
382 'header': item['title'],
383 'created': datetime_from_monorail(item['published']),
384 'modified': datetime_from_monorail(item['updated']),
385 'author': item['author']['name'],
386 'url': item_url,
387 'comments': [],
388 'status': item['status'],
389 'labels': [],
390 'components': []
391 }
392 if 'owner' in item:
393 issue['owner'] = item['owner']['name']
394 else:
395 issue['owner'] = 'None'
396 if 'labels' in item:
397 issue['labels'] = item['labels']
398 if 'components' in item:
399 issue['components'] = item['components']
400 issues.append(issue)
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100401
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000402 return issues
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100403
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000404 def monorail_issue_search(self, project):
405 epoch = datetime.utcfromtimestamp(0)
406 # Defaults to @chromium.org email if one wasn't provided on -u option.
407 user_str = (self.options.email if self.options.email.find('@') >= 0 else
408 '%s@chromium.org' % self.user)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000409
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000410 issues = self.monorail_query_issues(
411 project, {
412 'maxResults':
413 10000,
414 'q':
415 user_str,
416 'publishedMax':
417 '%d' % (self.modified_before - epoch).total_seconds(),
418 'updatedMin':
419 '%d' % (self.modified_after - epoch).total_seconds(),
420 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000421
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000422 if self.options.completed_issues:
423 return [
424 issue for issue in issues
425 if (self.match(issue['owner']) and issue['status'].lower() in (
426 'verified', 'fixed'))
427 ]
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +0000428
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000429 return [
430 issue for issue in issues
431 if user_str in (issue['author'], issue['owner'])
432 ]
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000433
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000434 def monorail_get_issues(self, project, issue_ids):
435 return self.monorail_query_issues(project, {
436 'maxResults': 10000,
437 'q': 'id:%s' % ','.join(issue_ids)
438 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000439
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000440 def print_heading(self, heading):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000441 print()
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000442 print(self.options.output_format_heading.format(heading=heading))
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100443
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000444 def match(self, author):
445 if '@' in self.user:
446 return author == self.user
447 return author.startswith(self.user + '@')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100448
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000449 def print_change(self, change):
450 activity = len([
451 reply for reply in change['replies'] if self.match(reply['author'])
452 ])
453 optional_values = {
454 'created': change['created'].date().isoformat(),
455 'modified': change['modified'].date().isoformat(),
456 'reviewers': ', '.join(change['reviewers']),
457 'status': change['status'],
458 'activity': activity,
459 }
460 if self.options.deltas:
461 optional_values['delta'] = change['delta']
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100462
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000463 self.print_generic(self.options.output_format,
464 self.options.output_format_changes, change['header'],
465 change['review_url'], change['author'],
466 change['created'], change['modified'],
467 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000468
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000469 def print_issue(self, issue):
470 optional_values = {
471 'created': issue['created'].date().isoformat(),
472 'modified': issue['modified'].date().isoformat(),
473 'owner': issue['owner'],
474 'status': issue['status'],
475 }
476 self.print_generic(self.options.output_format,
477 self.options.output_format_issues, issue['header'],
478 issue['url'], issue['author'], issue['created'],
479 issue['modified'], optional_values)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000480
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000481 def print_review(self, review):
482 activity = len([
483 reply for reply in review['replies'] if self.match(reply['author'])
484 ])
485 optional_values = {
486 'created': review['created'].date().isoformat(),
487 'modified': review['modified'].date().isoformat(),
488 'status': review['status'],
489 'activity': activity,
490 }
491 if self.options.deltas:
492 optional_values['delta'] = review['delta']
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000493
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000494 self.print_generic(self.options.output_format,
495 self.options.output_format_reviews, review['header'],
496 review['review_url'], review['author'],
497 review['created'], review['modified'],
498 optional_values)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000499
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000500 @staticmethod
501 def print_generic(default_fmt,
502 specific_fmt,
503 title,
504 url,
505 author,
506 created,
507 modified,
508 optional_values=None):
509 output_format = (specific_fmt
510 if specific_fmt is not None else default_fmt)
511 values = {
512 'title': title,
513 'url': url,
514 'author': author,
515 'created': created,
516 'modified': modified,
517 }
518 if optional_values is not None:
519 values.update(optional_values)
520 print(DefaultFormatter().format(output_format, **values))
521
522 def filter_issue(self, issue, should_filter_by_user=True):
523 def maybe_filter_username(email):
524 return not should_filter_by_user or username(email) == self.user
525
526 if (maybe_filter_username(issue['author'])
527 and self.filter_modified(issue['created'])):
528 return True
529 if (maybe_filter_username(issue['owner'])
530 and (self.filter_modified(issue['created'])
531 or self.filter_modified(issue['modified']))):
532 return True
533 for reply in issue['replies']:
534 if self.filter_modified(reply['created']):
535 if not should_filter_by_user:
536 break
537 if (username(reply['author']) == self.user
538 or (self.user + '@') in reply['content']):
539 break
540 else:
541 return False
542 return True
543
544 def filter_modified(self, modified):
545 return self.modified_after < modified < self.modified_before
546
547 def auth_for_changes(self):
548 #TODO(cjhopman): Move authentication check for getting changes here.
549 pass
550
551 def auth_for_reviews(self):
552 # Reviews use all the same instances as changes so no authentication is
553 # required.
554 pass
555
556 def get_changes(self):
557 num_instances = len(gerrit_instances)
558 with contextlib.closing(ThreadPool(num_instances)) as pool:
559 gerrit_changes = pool.map_async(
560 lambda instance: self.gerrit_search(instance, owner=self.user),
561 gerrit_instances)
562 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
563 self.changes = list(gerrit_changes)
564
565 def print_changes(self):
566 if self.changes:
567 self.print_heading('Changes')
568 for change in self.changes:
569 self.print_change(change)
570
571 def print_access_errors(self):
572 if self.access_errors:
573 logging.error('Access Errors:')
574 for error in self.access_errors:
575 logging.error(error.rstrip())
576
577 def get_reviews(self):
578 num_instances = len(gerrit_instances)
579 with contextlib.closing(ThreadPool(num_instances)) as pool:
580 gerrit_reviews = pool.map_async(
581 lambda instance: self.gerrit_search(instance,
582 reviewer=self.user),
583 gerrit_instances)
584 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
585 self.reviews = list(gerrit_reviews)
586
587 def print_reviews(self):
588 if self.reviews:
589 self.print_heading('Reviews')
590 for review in self.reviews:
591 self.print_review(review)
592
593 def get_issues(self):
594 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
595 monorail_issues = pool.map(self.monorail_issue_search,
596 monorail_projects.keys())
597 monorail_issues = list(
598 itertools.chain.from_iterable(monorail_issues))
599
600 if not monorail_issues:
601 return
602
603 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
604 filtered_issues = pool.map(self.filter_modified_monorail_issue,
605 monorail_issues)
606 self.issues = [issue for issue in filtered_issues if issue]
607
608 def get_referenced_issues(self):
609 if not self.issues:
610 self.get_issues()
611
612 if not self.changes:
613 self.get_changes()
614
615 referenced_issue_uids = set(
616 itertools.chain.from_iterable(change['bugs']
617 for change in self.changes))
618 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
619 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
620
621 missing_issues_by_project = collections.defaultdict(list)
622 for issue_uid in missing_issue_uids:
623 project, issue_id = issue_uid.split(':')
624 missing_issues_by_project[project].append(issue_id)
625
626 for project, issue_ids in missing_issues_by_project.items():
627 self.referenced_issues += self.monorail_get_issues(
628 project, issue_ids)
629
630 def print_issues(self):
631 if self.issues:
632 self.print_heading('Issues')
633 for issue in self.issues:
634 self.print_issue(issue)
635
636 def print_changes_by_issue(self, skip_empty_own):
637 if not self.issues or not self.changes:
638 return
639
640 self.print_heading('Changes by referenced issue(s)')
641 issues = {issue['uid']: issue for issue in self.issues}
642 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
643 changes_by_issue_uid = collections.defaultdict(list)
644 changes_by_ref_issue_uid = collections.defaultdict(list)
645 changes_without_issue = []
646 for change in self.changes:
647 added = False
648 for issue_uid in change['bugs']:
649 if issue_uid in issues:
650 changes_by_issue_uid[issue_uid].append(change)
651 added = True
652 if issue_uid in ref_issues:
653 changes_by_ref_issue_uid[issue_uid].append(change)
654 added = True
655 if not added:
656 changes_without_issue.append(change)
657
658 # Changes referencing own issues.
659 for issue_uid in issues:
660 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
661 self.print_issue(issues[issue_uid])
662 if changes_by_issue_uid[issue_uid]:
663 print()
664 for change in changes_by_issue_uid[issue_uid]:
665 print(' ', end='') # this prints no newline
666 self.print_change(change)
667 print()
668
669 # Changes referencing others' issues.
670 for issue_uid in ref_issues:
671 assert changes_by_ref_issue_uid[issue_uid]
672 self.print_issue(ref_issues[issue_uid])
673 for change in changes_by_ref_issue_uid[issue_uid]:
674 print('', end=' '
675 ) # this prints one space due to comma, but no newline
676 self.print_change(change)
677
678 # Changes referencing no issues.
679 if changes_without_issue:
680 print(
681 self.options.output_format_no_url.format(title='Other changes'))
682 for change in changes_without_issue:
683 print('', end=' '
684 ) # this prints one space due to comma, but no newline
685 self.print_change(change)
686
687 def print_activity(self):
688 self.print_changes()
689 self.print_reviews()
690 self.print_issues()
691
692 def dump_json(self, ignore_keys=None):
693 if ignore_keys is None:
694 ignore_keys = ['replies']
695
696 def format_for_json_dump(in_array):
697 output = {}
698 for item in in_array:
699 url = item.get('url') or item.get('review_url')
700 if not url:
701 raise Exception('Dumped item %s does not specify url' %
702 item)
703 output[url] = dict(
704 (k, v) for k, v in item.items() if k not in ignore_keys)
705 return output
706
707 class PythonObjectEncoder(json.JSONEncoder):
708 def default(self, o): # pylint: disable=method-hidden
709 if isinstance(o, datetime):
710 return o.isoformat()
711 if isinstance(o, set):
712 return list(o)
713 return json.JSONEncoder.default(self, o)
714
715 output = {
716 'reviews': format_for_json_dump(self.reviews),
717 'changes': format_for_json_dump(self.changes),
718 'issues': format_for_json_dump(self.issues)
719 }
720 print(json.dumps(output, indent=2, cls=PythonObjectEncoder))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000721
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000722
723def main():
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000724 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
725 parser.add_option(
726 '-u',
727 '--user',
728 metavar='<email>',
729 # Look for USER and USERNAME (Windows) environment variables.
730 default=os.environ.get('USER', os.environ.get('USERNAME')),
731 help='Filter on user, default=%default')
732 parser.add_option('-b',
733 '--begin',
734 metavar='<date>',
735 help='Filter issues created after the date (mm/dd/yy)')
736 parser.add_option('-e',
737 '--end',
738 metavar='<date>',
739 help='Filter issues created before the date (mm/dd/yy)')
740 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
741 relativedelta(months=2))
742 parser.add_option(
743 '-Q',
744 '--last_quarter',
745 action='store_true',
746 help='Use last quarter\'s dates, i.e. %s to %s' %
747 (quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
748 parser.add_option('-Y',
749 '--this_year',
750 action='store_true',
751 help='Use this year\'s dates')
752 parser.add_option('-w',
753 '--week_of',
754 metavar='<date>',
755 help='Show issues for week of the date (mm/dd/yy)')
756 parser.add_option(
757 '-W',
758 '--last_week',
759 action='count',
760 help='Show last week\'s issues. Use more times for more weeks.')
761 parser.add_option(
762 '-a',
763 '--auth',
764 action='store_true',
765 help='Ask to authenticate for instances with no auth cookie')
766 parser.add_option('-d',
767 '--deltas',
768 action='store_true',
769 help='Fetch deltas for changes.')
770 parser.add_option(
771 '--no-referenced-issues',
772 action='store_true',
773 help='Do not fetch issues referenced by owned changes. Useful in '
774 'combination with --changes-by-issue when you only want to list '
775 'issues that have also been modified in the same time period.')
776 parser.add_option(
777 '--skip_servers',
778 action='store',
779 default='',
780 help='A comma separated list of gerrit and rietveld servers to ignore')
781 parser.add_option(
782 '--skip-own-issues-without-changes',
783 action='store_true',
784 help='Skips listing own issues without changes when showing changes '
785 'grouped by referenced issue(s). See --changes-by-issue for more '
786 'details.')
787 parser.add_option(
788 '-F',
789 '--config_file',
790 metavar='<config_file>',
791 help='Configuration file in JSON format, used to add additional gerrit '
792 'instances (see source code for an example).')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000793
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000794 activity_types_group = optparse.OptionGroup(
795 parser, 'Activity Types',
796 'By default, all activity will be looked up and '
797 'printed. If any of these are specified, only '
798 'those specified will be searched.')
799 activity_types_group.add_option('-c',
800 '--changes',
801 action='store_true',
802 help='Show changes.')
803 activity_types_group.add_option('-i',
804 '--issues',
805 action='store_true',
806 help='Show issues.')
807 activity_types_group.add_option('-r',
808 '--reviews',
809 action='store_true',
810 help='Show reviews.')
811 activity_types_group.add_option(
812 '--changes-by-issue',
813 action='store_true',
814 help='Show changes grouped by referenced issue(s).')
815 parser.add_option_group(activity_types_group)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000816
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000817 output_format_group = optparse.OptionGroup(
818 parser, 'Output Format',
819 'By default, all activity will be printed in the '
820 'following format: {url} {title}. This can be '
821 'changed for either all activity types or '
822 'individually for each activity type. The format '
823 'is defined as documented for '
824 'string.format(...). The variables available for '
825 'all activity types are url, title, author, '
826 'created and modified. Format options for '
827 'specific activity types will override the '
828 'generic format.')
829 output_format_group.add_option(
830 '-f',
831 '--output-format',
832 metavar='<format>',
833 default=u'{url} {title}',
834 help='Specifies the format to use when printing all your activity.')
835 output_format_group.add_option(
836 '--output-format-changes',
837 metavar='<format>',
838 default=None,
839 help='Specifies the format to use when printing changes. Supports the '
840 'additional variable {reviewers}')
841 output_format_group.add_option(
842 '--output-format-issues',
843 metavar='<format>',
844 default=None,
845 help='Specifies the format to use when printing issues. Supports the '
846 'additional variable {owner}.')
847 output_format_group.add_option(
848 '--output-format-reviews',
849 metavar='<format>',
850 default=None,
851 help='Specifies the format to use when printing reviews.')
852 output_format_group.add_option(
853 '--output-format-heading',
854 metavar='<format>',
855 default=u'{heading}:',
856 help='Specifies the format to use when printing headings. '
857 'Supports the variable {heading}.')
858 output_format_group.add_option(
859 '--output-format-no-url',
860 default='{title}',
861 help='Specifies the format to use when printing activity without url.')
862 output_format_group.add_option(
863 '-m',
864 '--markdown',
865 action='store_true',
866 help='Use markdown-friendly output (overrides --output-format '
867 'and --output-format-heading)')
868 output_format_group.add_option(
869 '-j',
870 '--json',
871 action='store_true',
872 help='Output json data (overrides other format options)')
873 parser.add_option_group(output_format_group)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000874
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000875 parser.add_option('-v',
876 '--verbose',
877 action='store_const',
878 dest='verbosity',
879 default=logging.WARN,
880 const=logging.INFO,
881 help='Output extra informational messages.')
882 parser.add_option('-q',
883 '--quiet',
884 action='store_const',
885 dest='verbosity',
886 const=logging.ERROR,
887 help='Suppress non-error messages.')
888 parser.add_option('-M',
889 '--merged-only',
890 action='store_true',
891 dest='merged_only',
892 default=False,
893 help='Shows only changes that have been merged.')
894 parser.add_option(
895 '-C',
896 '--completed-issues',
897 action='store_true',
898 dest='completed_issues',
899 default=False,
900 help='Shows only monorail issues that have completed (Fixed|Verified) '
901 'by the user.')
902 parser.add_option(
903 '-o',
904 '--output',
905 metavar='<file>',
906 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000907
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000908 # Remove description formatting
909 parser.format_description = (lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000910
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000911 options, args = parser.parse_args()
912 options.local_user = os.environ.get('USER')
913 if args:
914 parser.error('Args unsupported')
915 if not options.user:
916 parser.error('USER/USERNAME is not set, please use -u')
917 # Retains the original -u option as the email address.
918 options.email = options.user
919 options.user = username(options.email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000920
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000921 logging.basicConfig(level=options.verbosity)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000922
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000923 # python-keyring provides easy access to the system keyring.
924 try:
925 import keyring # pylint: disable=unused-import,unused-variable,F0401
926 except ImportError:
927 logging.warning('Consider installing python-keyring')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000928
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000929 if not options.begin:
930 if options.last_quarter:
931 begin, end = quarter_begin, quarter_end
932 elif options.this_year:
933 begin, end = get_year_of(datetime.today())
934 elif options.week_of:
935 begin, end = (get_week_of(
936 datetime.strptime(options.week_of, '%m/%d/%y')))
937 elif options.last_week:
938 begin, end = (
939 get_week_of(datetime.today() -
940 timedelta(days=1 + 7 * options.last_week)))
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +0000941 else:
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000942 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000943 else:
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000944 begin = dateutil.parser.parse(options.begin)
945 if options.end:
946 end = dateutil.parser.parse(options.end)
947 else:
948 end = datetime.today()
949 options.begin, options.end = begin, end
950 if begin >= end:
951 # The queries fail in peculiar ways when the begin date is in the
952 # future. Give a descriptive error message instead.
953 logging.error(
954 'Start date (%s) is the same or later than end date (%s)' %
955 (begin, end))
956 return 1
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000957
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000958 if options.markdown:
959 options.output_format_heading = '### {heading}\n'
960 options.output_format = ' * [{title}]({url})'
961 options.output_format_no_url = ' * {title}'
962 logging.info('Searching for activity by %s', options.user)
963 logging.info('Using range %s to %s', options.begin, options.end)
964
965 if options.config_file:
966 with open(options.config_file) as f:
967 config = json.load(f)
968
969 for item, entries in config.items():
970 if item == 'gerrit_instances':
971 for repo, dic in entries.items():
972 # Use property name as URL
973 dic['url'] = repo
974 gerrit_instances.append(dic)
975 elif item == 'monorail_projects':
976 monorail_projects.append(entries)
977 else:
978 logging.error('Invalid entry in config file.')
979 return 1
980
981 my_activity = MyActivity(options)
982 my_activity.show_progress('Loading data')
983
984 if not (options.changes or options.reviews or options.issues
985 or options.changes_by_issue):
986 options.changes = True
987 options.issues = True
988 options.reviews = True
989
990 # First do any required authentication so none of the user interaction has
991 # to wait for actual work.
992 if options.changes or options.changes_by_issue:
993 my_activity.auth_for_changes()
994 if options.reviews:
995 my_activity.auth_for_reviews()
996
997 logging.info('Looking up activity.....')
998
999 try:
1000 if options.changes or options.changes_by_issue:
1001 my_activity.get_changes()
1002 if options.reviews:
1003 my_activity.get_reviews()
1004 if options.issues or options.changes_by_issue:
1005 my_activity.get_issues()
1006 if not options.no_referenced_issues:
1007 my_activity.get_referenced_issues()
1008 except auth.LoginRequiredError as e:
1009 logging.error('auth.LoginRequiredError: %s', e)
1010
1011 my_activity.show_progress('\n')
1012
1013 my_activity.print_access_errors()
1014
1015 output_file = None
1016 try:
1017 if options.output:
1018 output_file = open(options.output, 'w')
1019 logging.info('Printing output to "%s"', options.output)
1020 sys.stdout = output_file
1021 except (IOError, OSError) as e:
1022 logging.error('Unable to write output: %s', e)
1023 else:
1024 if options.json:
1025 my_activity.dump_json()
1026 else:
1027 if options.changes:
1028 my_activity.print_changes()
1029 if options.reviews:
1030 my_activity.print_reviews()
1031 if options.issues:
1032 my_activity.print_issues()
1033 if options.changes_by_issue:
1034 my_activity.print_changes_by_issue(
1035 options.skip_own_issues_without_changes)
1036 finally:
1037 if output_file:
1038 logging.info('Done printing to file.')
1039 sys.stdout = sys.__stdout__
1040 output_file.close()
1041
1042 return 0
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001043
1044
1045if __name__ == '__main__':
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001046 # Fix encoding to support non-ascii issue titles.
1047 fix_encoding.fix_encoding()
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001048
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001049 try:
1050 sys.exit(main())
1051 except KeyboardInterrupt:
1052 sys.stderr.write('interrupted\n')
1053 sys.exit(1)