blob: e30849170be9825b015f9e5d6fe3c362cf90374d [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
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +010035import collections
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +010036import contextlib
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000037from datetime import datetime
38from datetime import timedelta
Edward Lemur202c5592019-10-21 22:44:52 +000039import httplib2
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +010040import itertools
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000041import json
Tobias Sargeantffb3c432017-03-08 14:09:14 +000042import logging
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +010043from multiprocessing.pool import ThreadPool
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000044import optparse
45import os
Tobias Sargeantffb3c432017-03-08 14:09:14 +000046from string import Formatter
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000047import sys
48import urllib
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000049import re
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000050
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000051import auth
stevefung@chromium.org832d51e2015-05-27 12:52:51 +000052import fix_encoding
Edward Lesmesae3586b2020-03-23 21:21:14 +000053import gclient_utils
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000054import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000055
Edward Lemur2a048032020-01-14 22:58:13 +000056if sys.version_info.major == 2:
Mike Frysinger124bb8e2023-09-06 05:48:55 +000057 logging.critical(
58 'Python 2 is not supported. Run my_activity.py using vpython3.')
Edward Lemur2a048032020-01-14 22:58:13 +000059
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000060try:
Mike Frysinger124bb8e2023-09-06 05:48:55 +000061 import dateutil # pylint: disable=import-error
62 import dateutil.parser
63 from dateutil.relativedelta import relativedelta
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000064except ImportError:
Mike Frysinger124bb8e2023-09-06 05:48:55 +000065 logging.error('python-dateutil package required')
66 sys.exit(1)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000067
Tobias Sargeantffb3c432017-03-08 14:09:14 +000068
69class DefaultFormatter(Formatter):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000070 def __init__(self, default=''):
71 super(DefaultFormatter, self).__init__()
72 self.default = default
Tobias Sargeantffb3c432017-03-08 14:09:14 +000073
Mike Frysinger124bb8e2023-09-06 05:48:55 +000074 def get_value(self, key, args, kwargs):
75 if isinstance(key, str) and key not in kwargs:
76 return self.default
77 return Formatter.get_value(self, key, args, kwargs)
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +000078
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000079
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000080gerrit_instances = [
Mike Frysinger124bb8e2023-09-06 05:48:55 +000081 {
82 'url': 'android-review.googlesource.com',
83 'shorturl': 'r.android.com',
84 'short_url_protocol': 'https',
85 },
86 {
87 'url': 'gerrit-review.googlesource.com',
88 },
89 {
90 'url': 'chrome-internal-review.googlesource.com',
91 'shorturl': 'crrev.com/i',
92 'short_url_protocol': 'https',
93 },
94 {
95 'url': 'chromium-review.googlesource.com',
96 'shorturl': 'crrev.com/c',
97 'short_url_protocol': 'https',
98 },
99 {
100 'url': 'dawn-review.googlesource.com',
101 },
102 {
103 'url': 'pdfium-review.googlesource.com',
104 },
105 {
106 'url': 'skia-review.googlesource.com',
107 },
108 {
109 'url': 'review.coreboot.org',
110 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000111]
112
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100113monorail_projects = {
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000114 'angleproject': {
115 'shorturl': 'anglebug.com',
116 'short_url_protocol': 'http',
117 },
118 'chromium': {
119 'shorturl': 'crbug.com',
120 'short_url_protocol': 'https',
121 },
122 'dawn': {},
123 'google-breakpad': {},
124 'gyp': {},
125 'pdfium': {
126 'shorturl': 'crbug.com/pdfium',
127 'short_url_protocol': 'https',
128 },
129 'skia': {},
130 'tint': {},
131 'v8': {
132 'shorturl': 'crbug.com/v8',
133 'short_url_protocol': 'https',
134 },
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100135}
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000136
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000137
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000138def username(email):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000139 """Keeps the username of an email address."""
140 return email and email.split('@', 1)[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000141
142
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000143def datetime_to_midnight(date):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000144 return date - timedelta(hours=date.hour,
145 minutes=date.minute,
146 seconds=date.second,
147 microseconds=date.microsecond)
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000148
149
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000150def get_quarter_of(date):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000151 begin = (datetime_to_midnight(date) -
152 relativedelta(months=(date.month - 1) % 3, days=(date.day - 1)))
153 return begin, begin + relativedelta(months=3)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000154
155
156def get_year_of(date):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000157 begin = (datetime_to_midnight(date) -
158 relativedelta(months=(date.month - 1), days=(date.day - 1)))
159 return begin, begin + relativedelta(years=1)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000160
161
162def get_week_of(date):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000163 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
164 return begin, begin + timedelta(days=7)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000165
166
167def get_yes_or_no(msg):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000168 while True:
169 response = gclient_utils.AskForData(msg + ' yes/no [no] ')
170 if response in ('y', 'yes'):
171 return True
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +0000172
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000173 if not response or response in ('n', 'no'):
174 return False
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000175
176
deymo@chromium.org6c039202013-09-12 12:28:12 +0000177def datetime_from_gerrit(date_string):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000178 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000179
180
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100181def datetime_from_monorail(date_string):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000182 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S')
183
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000184
Edward Lemurab9cc2c2020-02-25 23:26:24 +0000185def extract_bug_numbers_from_description(issue):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000186 # Getting the description for REST Gerrit
187 revision = issue['revisions'][issue['current_revision']]
188 description = revision['commit']['message']
Edward Lemurab9cc2c2020-02-25 23:26:24 +0000189
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000190 bugs = []
191 # Handle both "Bug: 99999" and "BUG=99999" bug notations
192 # Multiple bugs can be noted on a single line or in multiple ones.
193 matches = re.findall(
194 r'^(BUG=|(Bug|Fixed):\s*)((((?:[a-zA-Z0-9-]+:)?\d+)(,\s?)?)+)',
195 description,
196 flags=re.IGNORECASE | re.MULTILINE)
197 if matches:
198 for match in matches:
199 bugs.extend(match[2].replace(' ', '').split(','))
200 # Add default chromium: prefix if none specified.
201 bugs = [bug if ':' in bug else 'chromium:%s' % bug for bug in bugs]
Edward Lemurab9cc2c2020-02-25 23:26:24 +0000202
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000203 return sorted(set(bugs))
204
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000205
206class MyActivity(object):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000207 def __init__(self, options):
208 self.options = options
209 self.modified_after = options.begin
210 self.modified_before = options.end
211 self.user = options.user
212 self.changes = []
213 self.reviews = []
214 self.issues = []
215 self.referenced_issues = []
216 self.google_code_auth_token = None
217 self.access_errors = set()
218 self.skip_servers = (options.skip_servers.split(','))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000219
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000220 def show_progress(self, how='.'):
221 if sys.stdout.isatty():
222 sys.stdout.write(how)
223 sys.stdout.flush()
Sergiy Byelozyorova68d82c2018-03-21 17:20:56 +0100224
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000225 def gerrit_changes_over_rest(self, instance, filters):
226 # Convert the "key:value" filter to a list of (key, value) pairs.
227 req = list(f.split(':', 1) for f in filters)
228 try:
229 # Instantiate the generator to force all the requests now and catch
230 # the errors here.
231 return list(
232 gerrit_util.GenerateAllChanges(instance['url'],
233 req,
234 o_params=[
235 'MESSAGES', 'LABELS',
236 'DETAILED_ACCOUNTS',
237 'CURRENT_REVISION',
238 'CURRENT_COMMIT'
239 ]))
240 except gerrit_util.GerritError as e:
241 error_message = 'Looking up %r: %s' % (instance['url'], e)
242 if error_message not in self.access_errors:
243 self.access_errors.add(error_message)
244 return []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000245
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000246 def gerrit_search(self, instance, owner=None, reviewer=None):
247 if instance['url'] in self.skip_servers:
248 return []
249 max_age = datetime.today() - self.modified_after
250 filters = ['-age:%ss' % (max_age.days * 24 * 3600 + max_age.seconds)]
251 if owner:
252 assert not reviewer
253 filters.append('owner:%s' % owner)
254 else:
255 filters.extend(('-owner:%s' % reviewer, 'reviewer:%s' % reviewer))
256 # TODO(cjhopman): Should abandoned changes be filtered out when
257 # merged_only is not enabled?
258 if self.options.merged_only:
259 filters.append('status:merged')
deymo@chromium.org6c039202013-09-12 12:28:12 +0000260
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000261 issues = self.gerrit_changes_over_rest(instance, filters)
262 self.show_progress()
263 issues = [
264 self.process_gerrit_issue(instance, issue) for issue in issues
265 ]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000266
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000267 issues = filter(self.filter_issue, issues)
268 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000269
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000270 return issues
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000271
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000272 def process_gerrit_issue(self, instance, issue):
273 ret = {}
274 if self.options.deltas:
275 ret['delta'] = DefaultFormatter().format(
276 '+{insertions},-{deletions}', **issue)
277 ret['status'] = issue['status']
278 if 'shorturl' in instance:
279 protocol = instance.get('short_url_protocol', 'http')
280 url = instance['shorturl']
281 else:
282 protocol = 'https'
283 url = instance['url']
284 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700285
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000286 ret['header'] = issue['subject']
287 ret['owner'] = issue['owner'].get('email', '')
288 ret['author'] = ret['owner']
289 ret['created'] = datetime_from_gerrit(issue['created'])
290 ret['modified'] = datetime_from_gerrit(issue['updated'])
291 if 'messages' in issue:
292 ret['replies'] = self.process_gerrit_issue_replies(
293 issue['messages'])
294 else:
295 ret['replies'] = []
296 ret['reviewers'] = set(r['author'] for r in ret['replies'])
297 ret['reviewers'].discard(ret['author'])
298 ret['bugs'] = extract_bug_numbers_from_description(issue)
299 return ret
deymo@chromium.org6c039202013-09-12 12:28:12 +0000300
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000301 @staticmethod
302 def process_gerrit_issue_replies(replies):
303 ret = []
304 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
305 replies)
306 for reply in replies:
307 ret.append({
308 'author': reply['author']['email'],
309 'created': datetime_from_gerrit(reply['date']),
310 'content': reply['message'],
311 })
312 return ret
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000313
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000314 def monorail_get_auth_http(self):
315 # Manually use a long timeout (10m); for some users who have a
316 # long history on the issue tracker, whatever the default timeout
317 # is is reached.
318 return auth.Authenticator().authorize(httplib2.Http(timeout=600))
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100319
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000320 def filter_modified_monorail_issue(self, issue):
321 """Precisely checks if an issue has been modified in the time range.
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100322
323 This fetches all issue comments to check if the issue has been modified in
324 the time range specified by user. This is needed because monorail only
325 allows filtering by last updated and published dates, which is not
326 sufficient to tell whether a given issue has been modified at some specific
327 time range. Any update to the issue is a reported as comment on Monorail.
328
329 Args:
330 issue: Issue dict as returned by monorail_query_issues method. In
331 particular, must have a key 'uid' formatted as 'project:issue_id'.
332
333 Returns:
334 Passed issue if modified, None otherwise.
335 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000336 http = self.monorail_get_auth_http()
337 project, issue_id = issue['uid'].split(':')
338 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
339 '/%s/issues/%s/comments?maxResults=10000') % (project, issue_id)
340 _, body = http.request(url)
341 self.show_progress()
342 content = json.loads(body)
343 if not content:
344 logging.error('Unable to parse %s response from monorail.', project)
345 return issue
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100346
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000347 for item in content.get('items', []):
348 comment_published = datetime_from_monorail(item['published'])
349 if self.filter_modified(comment_published):
350 return issue
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100351
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000352 return None
Sergiy Byelozyorov1b7d56d2018-03-21 17:07:28 +0100353
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000354 def monorail_query_issues(self, project, query):
355 http = self.monorail_get_auth_http()
356 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
357 '/%s/issues') % project
358 query_data = urllib.parse.urlencode(query)
359 url = url + '?' + query_data
360 _, body = http.request(url)
361 self.show_progress()
362 content = json.loads(body)
363 if not content:
364 logging.error('Unable to parse %s response from monorail.', project)
365 return []
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100366
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000367 issues = []
368 project_config = monorail_projects.get(project, {})
369 for item in content.get('items', []):
370 if project_config.get('shorturl'):
371 protocol = project_config.get('short_url_protocol', 'http')
372 item_url = '%s://%s/%d' % (protocol, project_config['shorturl'],
373 item['id'])
374 else:
375 item_url = (
376 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' %
377 (project, item['id']))
378 issue = {
379 'uid': '%s:%s' % (project, item['id']),
380 'header': item['title'],
381 'created': datetime_from_monorail(item['published']),
382 'modified': datetime_from_monorail(item['updated']),
383 'author': item['author']['name'],
384 'url': item_url,
385 'comments': [],
386 'status': item['status'],
387 'labels': [],
388 'components': []
389 }
390 if 'owner' in item:
391 issue['owner'] = item['owner']['name']
392 else:
393 issue['owner'] = 'None'
394 if 'labels' in item:
395 issue['labels'] = item['labels']
396 if 'components' in item:
397 issue['components'] = item['components']
398 issues.append(issue)
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100399
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000400 return issues
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100401
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000402 def monorail_issue_search(self, project):
403 epoch = datetime.utcfromtimestamp(0)
404 # Defaults to @chromium.org email if one wasn't provided on -u option.
405 user_str = (self.options.email if self.options.email.find('@') >= 0 else
406 '%s@chromium.org' % self.user)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000407
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000408 issues = self.monorail_query_issues(
409 project, {
410 'maxResults':
411 10000,
412 'q':
413 user_str,
414 'publishedMax':
415 '%d' % (self.modified_before - epoch).total_seconds(),
416 'updatedMin':
417 '%d' % (self.modified_after - epoch).total_seconds(),
418 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000419
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000420 if self.options.completed_issues:
421 return [
422 issue for issue in issues
423 if (self.match(issue['owner']) and issue['status'].lower() in (
424 'verified', 'fixed'))
425 ]
Andrii Shyshkalov8bdc1b82018-09-24 17:29:41 +0000426
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000427 return [
428 issue for issue in issues
429 if user_str in (issue['author'], issue['owner'])
430 ]
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000431
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000432 def monorail_get_issues(self, project, issue_ids):
433 return self.monorail_query_issues(project, {
434 'maxResults': 10000,
435 'q': 'id:%s' % ','.join(issue_ids)
436 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000437
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000438 def print_heading(self, heading):
Raul Tambre80ee78e2019-05-06 22:41:05 +0000439 print()
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000440 print(self.options.output_format_heading.format(heading=heading))
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100441
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000442 def match(self, author):
443 if '@' in self.user:
444 return author == self.user
445 return author.startswith(self.user + '@')
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100446
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000447 def print_change(self, change):
448 activity = len([
449 reply for reply in change['replies'] if self.match(reply['author'])
450 ])
451 optional_values = {
452 'created': change['created'].date().isoformat(),
453 'modified': change['modified'].date().isoformat(),
454 'reviewers': ', '.join(change['reviewers']),
455 'status': change['status'],
456 'activity': activity,
457 }
458 if self.options.deltas:
459 optional_values['delta'] = change['delta']
Sergiy Byelozyorov544b7442018-03-16 21:44:58 +0100460
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000461 self.print_generic(self.options.output_format,
462 self.options.output_format_changes, change['header'],
463 change['review_url'], change['author'],
464 change['created'], change['modified'],
465 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000466
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000467 def print_issue(self, issue):
468 optional_values = {
469 'created': issue['created'].date().isoformat(),
470 'modified': issue['modified'].date().isoformat(),
471 'owner': issue['owner'],
472 'status': issue['status'],
473 }
474 self.print_generic(self.options.output_format,
475 self.options.output_format_issues, issue['header'],
476 issue['url'], issue['author'], issue['created'],
477 issue['modified'], optional_values)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000478
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000479 def print_review(self, review):
480 activity = len([
481 reply for reply in review['replies'] if self.match(reply['author'])
482 ])
483 optional_values = {
484 'created': review['created'].date().isoformat(),
485 'modified': review['modified'].date().isoformat(),
486 'status': review['status'],
487 'activity': activity,
488 }
489 if self.options.deltas:
490 optional_values['delta'] = review['delta']
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000491
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000492 self.print_generic(self.options.output_format,
493 self.options.output_format_reviews, review['header'],
494 review['review_url'], review['author'],
495 review['created'], review['modified'],
496 optional_values)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000497
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000498 @staticmethod
499 def print_generic(default_fmt,
500 specific_fmt,
501 title,
502 url,
503 author,
504 created,
505 modified,
506 optional_values=None):
507 output_format = (specific_fmt
508 if specific_fmt is not None else default_fmt)
509 values = {
510 'title': title,
511 'url': url,
512 'author': author,
513 'created': created,
514 'modified': modified,
515 }
516 if optional_values is not None:
517 values.update(optional_values)
518 print(DefaultFormatter().format(output_format, **values))
519
520 def filter_issue(self, issue, should_filter_by_user=True):
521 def maybe_filter_username(email):
522 return not should_filter_by_user or username(email) == self.user
523
524 if (maybe_filter_username(issue['author'])
525 and self.filter_modified(issue['created'])):
526 return True
527 if (maybe_filter_username(issue['owner'])
528 and (self.filter_modified(issue['created'])
529 or self.filter_modified(issue['modified']))):
530 return True
531 for reply in issue['replies']:
532 if self.filter_modified(reply['created']):
533 if not should_filter_by_user:
534 break
535 if (username(reply['author']) == self.user
536 or (self.user + '@') in reply['content']):
537 break
538 else:
539 return False
540 return True
541
542 def filter_modified(self, modified):
543 return self.modified_after < modified < self.modified_before
544
545 def auth_for_changes(self):
546 #TODO(cjhopman): Move authentication check for getting changes here.
547 pass
548
549 def auth_for_reviews(self):
550 # Reviews use all the same instances as changes so no authentication is
551 # required.
552 pass
553
554 def get_changes(self):
555 num_instances = len(gerrit_instances)
556 with contextlib.closing(ThreadPool(num_instances)) as pool:
557 gerrit_changes = pool.map_async(
558 lambda instance: self.gerrit_search(instance, owner=self.user),
559 gerrit_instances)
560 gerrit_changes = itertools.chain.from_iterable(gerrit_changes.get())
561 self.changes = list(gerrit_changes)
562
563 def print_changes(self):
564 if self.changes:
565 self.print_heading('Changes')
566 for change in self.changes:
567 self.print_change(change)
568
569 def print_access_errors(self):
570 if self.access_errors:
571 logging.error('Access Errors:')
572 for error in self.access_errors:
573 logging.error(error.rstrip())
574
575 def get_reviews(self):
576 num_instances = len(gerrit_instances)
577 with contextlib.closing(ThreadPool(num_instances)) as pool:
578 gerrit_reviews = pool.map_async(
579 lambda instance: self.gerrit_search(instance,
580 reviewer=self.user),
581 gerrit_instances)
582 gerrit_reviews = itertools.chain.from_iterable(gerrit_reviews.get())
583 self.reviews = list(gerrit_reviews)
584
585 def print_reviews(self):
586 if self.reviews:
587 self.print_heading('Reviews')
588 for review in self.reviews:
589 self.print_review(review)
590
591 def get_issues(self):
592 with contextlib.closing(ThreadPool(len(monorail_projects))) as pool:
593 monorail_issues = pool.map(self.monorail_issue_search,
594 monorail_projects.keys())
595 monorail_issues = list(
596 itertools.chain.from_iterable(monorail_issues))
597
598 if not monorail_issues:
599 return
600
601 with contextlib.closing(ThreadPool(len(monorail_issues))) as pool:
602 filtered_issues = pool.map(self.filter_modified_monorail_issue,
603 monorail_issues)
604 self.issues = [issue for issue in filtered_issues if issue]
605
606 def get_referenced_issues(self):
607 if not self.issues:
608 self.get_issues()
609
610 if not self.changes:
611 self.get_changes()
612
613 referenced_issue_uids = set(
614 itertools.chain.from_iterable(change['bugs']
615 for change in self.changes))
616 fetched_issue_uids = set(issue['uid'] for issue in self.issues)
617 missing_issue_uids = referenced_issue_uids - fetched_issue_uids
618
619 missing_issues_by_project = collections.defaultdict(list)
620 for issue_uid in missing_issue_uids:
621 project, issue_id = issue_uid.split(':')
622 missing_issues_by_project[project].append(issue_id)
623
624 for project, issue_ids in missing_issues_by_project.items():
625 self.referenced_issues += self.monorail_get_issues(
626 project, issue_ids)
627
628 def print_issues(self):
629 if self.issues:
630 self.print_heading('Issues')
631 for issue in self.issues:
632 self.print_issue(issue)
633
634 def print_changes_by_issue(self, skip_empty_own):
635 if not self.issues or not self.changes:
636 return
637
638 self.print_heading('Changes by referenced issue(s)')
639 issues = {issue['uid']: issue for issue in self.issues}
640 ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
641 changes_by_issue_uid = collections.defaultdict(list)
642 changes_by_ref_issue_uid = collections.defaultdict(list)
643 changes_without_issue = []
644 for change in self.changes:
645 added = False
646 for issue_uid in change['bugs']:
647 if issue_uid in issues:
648 changes_by_issue_uid[issue_uid].append(change)
649 added = True
650 if issue_uid in ref_issues:
651 changes_by_ref_issue_uid[issue_uid].append(change)
652 added = True
653 if not added:
654 changes_without_issue.append(change)
655
656 # Changes referencing own issues.
657 for issue_uid in issues:
658 if changes_by_issue_uid[issue_uid] or not skip_empty_own:
659 self.print_issue(issues[issue_uid])
660 if changes_by_issue_uid[issue_uid]:
661 print()
662 for change in changes_by_issue_uid[issue_uid]:
663 print(' ', end='') # this prints no newline
664 self.print_change(change)
665 print()
666
667 # Changes referencing others' issues.
668 for issue_uid in ref_issues:
669 assert changes_by_ref_issue_uid[issue_uid]
670 self.print_issue(ref_issues[issue_uid])
671 for change in changes_by_ref_issue_uid[issue_uid]:
672 print('', end=' '
673 ) # this prints one space due to comma, but no newline
674 self.print_change(change)
675
676 # Changes referencing no issues.
677 if changes_without_issue:
678 print(
679 self.options.output_format_no_url.format(title='Other changes'))
680 for change in changes_without_issue:
681 print('', end=' '
682 ) # this prints one space due to comma, but no newline
683 self.print_change(change)
684
685 def print_activity(self):
686 self.print_changes()
687 self.print_reviews()
688 self.print_issues()
689
690 def dump_json(self, ignore_keys=None):
691 if ignore_keys is None:
692 ignore_keys = ['replies']
693
694 def format_for_json_dump(in_array):
695 output = {}
696 for item in in_array:
697 url = item.get('url') or item.get('review_url')
698 if not url:
699 raise Exception('Dumped item %s does not specify url' %
700 item)
701 output[url] = dict(
702 (k, v) for k, v in item.items() if k not in ignore_keys)
703 return output
704
705 class PythonObjectEncoder(json.JSONEncoder):
706 def default(self, o): # pylint: disable=method-hidden
707 if isinstance(o, datetime):
708 return o.isoformat()
709 if isinstance(o, set):
710 return list(o)
711 return json.JSONEncoder.default(self, o)
712
713 output = {
714 'reviews': format_for_json_dump(self.reviews),
715 'changes': format_for_json_dump(self.changes),
716 'issues': format_for_json_dump(self.issues)
717 }
718 print(json.dumps(output, indent=2, cls=PythonObjectEncoder))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000719
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000720
721def main():
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000722 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
723 parser.add_option(
724 '-u',
725 '--user',
726 metavar='<email>',
727 # Look for USER and USERNAME (Windows) environment variables.
728 default=os.environ.get('USER', os.environ.get('USERNAME')),
729 help='Filter on user, default=%default')
730 parser.add_option('-b',
731 '--begin',
732 metavar='<date>',
733 help='Filter issues created after the date (mm/dd/yy)')
734 parser.add_option('-e',
735 '--end',
736 metavar='<date>',
737 help='Filter issues created before the date (mm/dd/yy)')
738 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
739 relativedelta(months=2))
740 parser.add_option(
741 '-Q',
742 '--last_quarter',
743 action='store_true',
744 help='Use last quarter\'s dates, i.e. %s to %s' %
745 (quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
746 parser.add_option('-Y',
747 '--this_year',
748 action='store_true',
749 help='Use this year\'s dates')
750 parser.add_option('-w',
751 '--week_of',
752 metavar='<date>',
753 help='Show issues for week of the date (mm/dd/yy)')
754 parser.add_option(
755 '-W',
756 '--last_week',
757 action='count',
758 help='Show last week\'s issues. Use more times for more weeks.')
759 parser.add_option(
760 '-a',
761 '--auth',
762 action='store_true',
763 help='Ask to authenticate for instances with no auth cookie')
764 parser.add_option('-d',
765 '--deltas',
766 action='store_true',
767 help='Fetch deltas for changes.')
768 parser.add_option(
769 '--no-referenced-issues',
770 action='store_true',
771 help='Do not fetch issues referenced by owned changes. Useful in '
772 'combination with --changes-by-issue when you only want to list '
773 'issues that have also been modified in the same time period.')
774 parser.add_option(
775 '--skip_servers',
776 action='store',
777 default='',
778 help='A comma separated list of gerrit and rietveld servers to ignore')
779 parser.add_option(
780 '--skip-own-issues-without-changes',
781 action='store_true',
782 help='Skips listing own issues without changes when showing changes '
783 'grouped by referenced issue(s). See --changes-by-issue for more '
784 'details.')
785 parser.add_option(
786 '-F',
787 '--config_file',
788 metavar='<config_file>',
789 help='Configuration file in JSON format, used to add additional gerrit '
790 'instances (see source code for an example).')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000791
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000792 activity_types_group = optparse.OptionGroup(
793 parser, 'Activity Types',
794 'By default, all activity will be looked up and '
795 'printed. If any of these are specified, only '
796 'those specified will be searched.')
797 activity_types_group.add_option('-c',
798 '--changes',
799 action='store_true',
800 help='Show changes.')
801 activity_types_group.add_option('-i',
802 '--issues',
803 action='store_true',
804 help='Show issues.')
805 activity_types_group.add_option('-r',
806 '--reviews',
807 action='store_true',
808 help='Show reviews.')
809 activity_types_group.add_option(
810 '--changes-by-issue',
811 action='store_true',
812 help='Show changes grouped by referenced issue(s).')
813 parser.add_option_group(activity_types_group)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000814
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000815 output_format_group = optparse.OptionGroup(
816 parser, 'Output Format',
817 'By default, all activity will be printed in the '
818 'following format: {url} {title}. This can be '
819 'changed for either all activity types or '
820 'individually for each activity type. The format '
821 'is defined as documented for '
822 'string.format(...). The variables available for '
823 'all activity types are url, title, author, '
824 'created and modified. Format options for '
825 'specific activity types will override the '
826 'generic format.')
827 output_format_group.add_option(
828 '-f',
829 '--output-format',
830 metavar='<format>',
831 default=u'{url} {title}',
832 help='Specifies the format to use when printing all your activity.')
833 output_format_group.add_option(
834 '--output-format-changes',
835 metavar='<format>',
836 default=None,
837 help='Specifies the format to use when printing changes. Supports the '
838 'additional variable {reviewers}')
839 output_format_group.add_option(
840 '--output-format-issues',
841 metavar='<format>',
842 default=None,
843 help='Specifies the format to use when printing issues. Supports the '
844 'additional variable {owner}.')
845 output_format_group.add_option(
846 '--output-format-reviews',
847 metavar='<format>',
848 default=None,
849 help='Specifies the format to use when printing reviews.')
850 output_format_group.add_option(
851 '--output-format-heading',
852 metavar='<format>',
853 default=u'{heading}:',
854 help='Specifies the format to use when printing headings. '
855 'Supports the variable {heading}.')
856 output_format_group.add_option(
857 '--output-format-no-url',
858 default='{title}',
859 help='Specifies the format to use when printing activity without url.')
860 output_format_group.add_option(
861 '-m',
862 '--markdown',
863 action='store_true',
864 help='Use markdown-friendly output (overrides --output-format '
865 'and --output-format-heading)')
866 output_format_group.add_option(
867 '-j',
868 '--json',
869 action='store_true',
870 help='Output json data (overrides other format options)')
871 parser.add_option_group(output_format_group)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000872
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000873 parser.add_option('-v',
874 '--verbose',
875 action='store_const',
876 dest='verbosity',
877 default=logging.WARN,
878 const=logging.INFO,
879 help='Output extra informational messages.')
880 parser.add_option('-q',
881 '--quiet',
882 action='store_const',
883 dest='verbosity',
884 const=logging.ERROR,
885 help='Suppress non-error messages.')
886 parser.add_option('-M',
887 '--merged-only',
888 action='store_true',
889 dest='merged_only',
890 default=False,
891 help='Shows only changes that have been merged.')
892 parser.add_option(
893 '-C',
894 '--completed-issues',
895 action='store_true',
896 dest='completed_issues',
897 default=False,
898 help='Shows only monorail issues that have completed (Fixed|Verified) '
899 'by the user.')
900 parser.add_option(
901 '-o',
902 '--output',
903 metavar='<file>',
904 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000905
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000906 # Remove description formatting
907 parser.format_description = (lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000908
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000909 options, args = parser.parse_args()
910 options.local_user = os.environ.get('USER')
911 if args:
912 parser.error('Args unsupported')
913 if not options.user:
914 parser.error('USER/USERNAME is not set, please use -u')
915 # Retains the original -u option as the email address.
916 options.email = options.user
917 options.user = username(options.email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000918
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000919 logging.basicConfig(level=options.verbosity)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000920
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000921 # python-keyring provides easy access to the system keyring.
922 try:
923 import keyring # pylint: disable=unused-import,unused-variable,F0401
924 except ImportError:
925 logging.warning('Consider installing python-keyring')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000926
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000927 if not options.begin:
928 if options.last_quarter:
929 begin, end = quarter_begin, quarter_end
930 elif options.this_year:
931 begin, end = get_year_of(datetime.today())
932 elif options.week_of:
933 begin, end = (get_week_of(
934 datetime.strptime(options.week_of, '%m/%d/%y')))
935 elif options.last_week:
936 begin, end = (
937 get_week_of(datetime.today() -
938 timedelta(days=1 + 7 * options.last_week)))
Aravind Vasudevanc5f0cbb2022-01-24 23:56:57 +0000939 else:
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000940 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000941 else:
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000942 begin = dateutil.parser.parse(options.begin)
943 if options.end:
944 end = dateutil.parser.parse(options.end)
945 else:
946 end = datetime.today()
947 options.begin, options.end = begin, end
948 if begin >= end:
949 # The queries fail in peculiar ways when the begin date is in the
950 # future. Give a descriptive error message instead.
951 logging.error(
952 'Start date (%s) is the same or later than end date (%s)' %
953 (begin, end))
954 return 1
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000955
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000956 if options.markdown:
957 options.output_format_heading = '### {heading}\n'
958 options.output_format = ' * [{title}]({url})'
959 options.output_format_no_url = ' * {title}'
960 logging.info('Searching for activity by %s', options.user)
961 logging.info('Using range %s to %s', options.begin, options.end)
962
963 if options.config_file:
964 with open(options.config_file) as f:
965 config = json.load(f)
966
967 for item, entries in config.items():
968 if item == 'gerrit_instances':
969 for repo, dic in entries.items():
970 # Use property name as URL
971 dic['url'] = repo
972 gerrit_instances.append(dic)
973 elif item == 'monorail_projects':
974 monorail_projects.append(entries)
975 else:
976 logging.error('Invalid entry in config file.')
977 return 1
978
979 my_activity = MyActivity(options)
980 my_activity.show_progress('Loading data')
981
982 if not (options.changes or options.reviews or options.issues
983 or options.changes_by_issue):
984 options.changes = True
985 options.issues = True
986 options.reviews = True
987
988 # First do any required authentication so none of the user interaction has
989 # to wait for actual work.
990 if options.changes or options.changes_by_issue:
991 my_activity.auth_for_changes()
992 if options.reviews:
993 my_activity.auth_for_reviews()
994
995 logging.info('Looking up activity.....')
996
997 try:
998 if options.changes or options.changes_by_issue:
999 my_activity.get_changes()
1000 if options.reviews:
1001 my_activity.get_reviews()
1002 if options.issues or options.changes_by_issue:
1003 my_activity.get_issues()
1004 if not options.no_referenced_issues:
1005 my_activity.get_referenced_issues()
1006 except auth.LoginRequiredError as e:
1007 logging.error('auth.LoginRequiredError: %s', e)
1008
1009 my_activity.show_progress('\n')
1010
1011 my_activity.print_access_errors()
1012
1013 output_file = None
1014 try:
1015 if options.output:
1016 output_file = open(options.output, 'w')
1017 logging.info('Printing output to "%s"', options.output)
1018 sys.stdout = output_file
1019 except (IOError, OSError) as e:
1020 logging.error('Unable to write output: %s', e)
1021 else:
1022 if options.json:
1023 my_activity.dump_json()
1024 else:
1025 if options.changes:
1026 my_activity.print_changes()
1027 if options.reviews:
1028 my_activity.print_reviews()
1029 if options.issues:
1030 my_activity.print_issues()
1031 if options.changes_by_issue:
1032 my_activity.print_changes_by_issue(
1033 options.skip_own_issues_without_changes)
1034 finally:
1035 if output_file:
1036 logging.info('Done printing to file.')
1037 sys.stdout = sys.__stdout__
1038 output_file.close()
1039
1040 return 0
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001041
1042
1043if __name__ == '__main__':
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001044 # Fix encoding to support non-ascii issue titles.
1045 fix_encoding.fix_encoding()
stevefung@chromium.org832d51e2015-05-27 12:52:51 +00001046
Mike Frysinger124bb8e2023-09-06 05:48:55 +00001047 try:
1048 sys.exit(main())
1049 except KeyboardInterrupt:
1050 sys.stderr.write('interrupted\n')
1051 sys.exit(1)