Add option for printing changes grouped by issue

This also adds support for V8 project on issue tracker.

R=tandrii@chromium.org

Change-Id: Ie90ae664573d36030267b639e8a55bc349cad872
Reviewed-on: https://chromium-review.googlesource.com/966623
Commit-Queue: Sergiy Byelozyorov <sergiyb@chromium.org>
Reviewed-by: Andrii Shyshkalov <tandrii@chromium.org>
diff --git a/my_activity.py b/my_activity.py
index d0f534f..6d2101c 100755
--- a/my_activity.py
+++ b/my_activity.py
@@ -24,9 +24,11 @@
 # check those details to determine if there was activity in the given period.
 # This means that query time scales mostly with (today() - begin).
 
+import collections
 from datetime import datetime
 from datetime import timedelta
 from functools import partial
+import itertools
 import json
 import logging
 import optparse
@@ -112,27 +114,23 @@
   },
 ]
 
-google_code_projects = [
-  {
-    'name': 'chromium',
+monorail_projects = {
+  'chromium': {
     'shorturl': 'crbug.com',
     'short_url_protocol': 'https',
   },
-  {
-    'name': 'google-breakpad',
-  },
-  {
-    'name': 'gyp',
-  },
-  {
-    'name': 'skia',
-  },
-  {
-    'name': 'pdfium',
+  'google-breakpad': {},
+  'gyp': {},
+  'skia': {},
+  'pdfium': {
     'shorturl': 'crbug.com/pdfium',
     'short_url_protocol': 'https',
   },
-]
+  'v8': {
+    'shorturl': 'crbug.com/v8',
+    'short_url_protocol': 'https',
+  },
+}
 
 def username(email):
   """Keeps the username of an email address."""
@@ -196,6 +194,7 @@
     self.changes = []
     self.reviews = []
     self.issues = []
+    self.referenced_issues = []
     self.check_cookies()
     self.google_code_auth_token = None
 
@@ -279,11 +278,14 @@
     if description:
       # Handle both "Bug: 99999" and "BUG=99999" bug notations
       # Multiple bugs can be noted on a single line or in multiple ones.
-      matches = re.findall(r'BUG[=:]\s?(((\d+)(,\s?)?)+)', description,
-                           flags=re.IGNORECASE)
+      matches = re.findall(
+          r'BUG[=:]\s?((((?:[a-zA-Z0-9-]+:)?\d+)(,\s?)?)+)', description,
+          flags=re.IGNORECASE)
       if matches:
         for match in matches:
           bugs.extend(match[0].replace(' ', '').split(','))
+        # Add default chromium: prefix if none specified.
+        bugs = [bug if ':' in bug else 'chromium:%s' % bug for bug in bugs]
 
     return bugs
 
@@ -327,7 +329,7 @@
     ret['created'] = datetime_from_rietveld(issue['created'])
     ret['replies'] = self.process_rietveld_replies(issue['messages'])
 
-    ret['bug'] = self.extract_bug_number_from_description(issue)
+    ret['bugs'] = self.extract_bug_number_from_description(issue)
     ret['landed_days_ago'] = issue['landed_days_ago']
 
     return ret
@@ -399,7 +401,7 @@
       ret['replies'] = []
     ret['reviewers'] = set(r['author'] for r in ret['replies'])
     ret['reviewers'].discard(ret['author'])
-    ret['bug'] = self.extract_bug_number_from_description(issue)
+    ret['bugs'] = self.extract_bug_number_from_description(issue)
     return ret
 
   @staticmethod
@@ -415,63 +417,75 @@
       })
     return ret
 
-  def project_hosting_issue_search(self, instance):
+  def monorail_query_issues(self, project, query):
+    project_config = monorail_projects.get(project, {})
     auth_config = auth.extract_auth_config_from_options(self.options)
     authenticator = auth.get_authenticator_for_host(
         'bugs.chromium.org', auth_config)
     http = authenticator.authorize(httplib2.Http())
     url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
-           '/%s/issues') % instance['name']
+           '/%s/issues') % project
+    query_data = urllib.urlencode(query)
+    url = url + '?' + query_data
+    _, body = http.request(url)
+    content = json.loads(body)
+    if not content:
+      logging.error('Unable to parse %s response from projecthosting.', project)
+      return []
+
+    issues = []
+    for item in content.get('items', []):
+      if project_config.get('shorturl'):
+        protocol = project_config.get('short_url_protocol', 'http')
+        item_url = '%s://%s/%d' % (
+            protocol, project_config['shorturl'], item['id'])
+      else:
+        item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
+            project, item['id'])
+      issue = {
+        'uid': '%s:%s' % (project, item['id']),
+        'header': item['title'],
+        'created': dateutil.parser.parse(item['published']),
+        'modified': dateutil.parser.parse(item['updated']),
+        'author': item['author']['name'],
+        'url': item_url,
+        'comments': [],
+        'status': item['status'],
+        'labels': [],
+        'components': []
+      }
+      if 'owner' in item:
+        issue['owner'] = item['owner']['name']
+      else:
+        issue['owner'] = 'None'
+      if 'labels' in item:
+        issue['labels'] = item['labels']
+      if 'components' in item:
+        issue['components'] = item['components']
+      issues.append(issue)
+
+    return issues
+
+  def monorail_issue_search(self, project):
     epoch = datetime.utcfromtimestamp(0)
     user_str = '%s@chromium.org' % self.user
 
-    query_data = urllib.urlencode({
+    issues = self.monorail_query_issues(project, {
       'maxResults': 10000,
       'q': user_str,
       'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
       'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
     })
-    url = url + '?' + query_data
-    _, body = http.request(url)
-    content = json.loads(body)
-    if not content:
-      logging.error('Unable to parse %s response from projecthosting.',
-          instance['name'])
-      return []
 
-    issues = []
-    if 'items' in content:
-      items = content['items']
-      for item in items:
-        if instance.get('shorturl'):
-          protocol = instance.get('short_url_protocol', 'http')
-          item_url = '%s://%s/%d' % (protocol, instance['shorturl'], item['id'])
-        else:
-          item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
-              instance['name'], item['id'])
-        issue = {
-          'header': item['title'],
-          'created': dateutil.parser.parse(item['published']),
-          'modified': dateutil.parser.parse(item['updated']),
-          'author': item['author']['name'],
-          'url': item_url,
-          'comments': [],
-          'status': item['status'],
-          'labels': [],
-          'components': []
-        }
-        if 'owner' in item:
-          issue['owner'] = item['owner']['name']
-        else:
-          issue['owner'] = 'None'
-        if issue['owner'] == user_str or issue['author'] == user_str:
-          issues.append(issue)
-        if 'labels' in item:
-          issue['labels'] = item['labels']
-        if 'components' in item:
-          issue['components'] = item['components']
+    return [
+        issue for issue in issues
+        if issue['author'] == user_str or issue['owner'] == user_str]
 
-    return issues
+  def monorail_get_issues(self, project, issue_ids):
+    return self.monorail_query_issues(project, {
+      'maxResults': 10000,
+      'q': 'id:%s' % ','.join(issue_ids)
+    })
 
   def print_heading(self, heading):
     print
@@ -602,7 +616,7 @@
     if self.changes:
       self.print_heading('Changes')
       for change in self.changes:
-        self.print_change(change)
+          self.print_change(change)
 
   def get_reviews(self):
     for instance in rietveld_instances:
@@ -620,8 +634,28 @@
         self.print_review(review)
 
   def get_issues(self):
-    for project in google_code_projects:
-      self.issues += self.project_hosting_issue_search(project)
+    for project in monorail_projects:
+      self.issues += self.monorail_issue_search(project)
+
+  def get_referenced_issues(self):
+    if not self.issues:
+      self.get_issues()
+
+    if not self.changes:
+      self.get_changes()
+
+    referenced_issue_uids = set(itertools.chain.from_iterable(
+      change['bugs'] for change in self.changes))
+    fetched_issue_uids = set(issue['uid'] for issue in self.issues)
+    missing_issue_uids = referenced_issue_uids - fetched_issue_uids
+
+    missing_issues_by_project = collections.defaultdict(list)
+    for issue_uid in missing_issue_uids:
+      project, issue_id = issue_uid.split(':')
+      missing_issues_by_project[project].append(issue_id)
+
+    for project, issue_ids in missing_issues_by_project.iteritems():
+      self.referenced_issues += self.monorail_get_issues(project, issue_ids)
 
   def print_issues(self):
     if self.issues:
@@ -629,6 +663,51 @@
       for issue in self.issues:
         self.print_issue(issue)
 
+  def print_changes_by_issue(self, skip_empty_own):
+    if not self.issues or not self.changes:
+      return
+
+    self.print_heading('Changes by referenced issue(s)')
+    issues = {issue['uid']: issue for issue in self.issues}
+    ref_issues = {issue['uid']: issue for issue in self.referenced_issues}
+    changes_by_issue_uid = collections.defaultdict(list)
+    changes_by_ref_issue_uid = collections.defaultdict(list)
+    changes_without_issue = []
+    for change in self.changes:
+      added = False
+      for issue_uid in change['bugs']:
+        if issue_uid in issues:
+          changes_by_issue_uid[issue_uid].append(change)
+          added = True
+        if issue_uid in ref_issues:
+          changes_by_ref_issue_uid[issue_uid].append(change)
+          added = True
+      if not added:
+        changes_without_issue.append(change)
+
+    # Changes referencing own issues.
+    for issue_uid in issues:
+      if changes_by_issue_uid[issue_uid] or not skip_empty_own:
+        self.print_issue(issues[issue_uid])
+      for change in changes_by_issue_uid[issue_uid]:
+        print '',  # this prints one space due to comma, but no newline
+        self.print_change(change)
+
+    # Changes referencing others' issues.
+    for issue_uid in ref_issues:
+      assert changes_by_ref_issue_uid[issue_uid]
+      self.print_issue(ref_issues[issue_uid])
+      for change in changes_by_ref_issue_uid[issue_uid]:
+        print '',  # this prints one space due to comma, but no newline
+        self.print_change(change)
+
+    # Changes referencing no issues.
+    if changes_without_issue:
+      print self.options.output_format_no_url.format(title='Other changes')
+      for change in changes_without_issue:
+        print '',  # this prints one space due to comma, but no newline
+        self.print_change(change)
+
   def print_activity(self):
     self.print_changes()
     self.print_reviews()
@@ -702,6 +781,18 @@
       '-d', '--deltas',
       action='store_true',
       help='Fetch deltas for changes.')
+  parser.add_option(
+      '--no-referenced-issues',
+      action='store_true',
+      help='Do not fetch issues referenced by owned changes. Useful in '
+           'combination with --changes-by-issue when you only want to list '
+           'issues that are your own in the output.')
+  parser.add_option(
+      '--skip-own-issues-without-changes',
+      action='store_true',
+      help='Skips listing own issues without changes when showing changes '
+           'grouped by referenced issue(s). See --changes-by-issue for more '
+           'details.')
 
   activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
                                'By default, all activity will be looked up and '
@@ -719,6 +810,9 @@
       '-r', '--reviews',
       action='store_true',
       help='Show reviews.')
+  activity_types_group.add_option(
+      '--changes-by-issue', action='store_true',
+      help='Show changes grouped by referenced issue(s).')
   parser.add_option_group(activity_types_group)
 
   output_format_group = optparse.OptionGroup(parser, 'Output Format',
@@ -754,6 +848,9 @@
       default=u'{heading}:',
       help='Specifies the format to use when printing headings.')
   output_format_group.add_option(
+      '--output-format-no-url', default='{title}',
+      help='Specifies the format to use when printing activity without url.')
+  output_format_group.add_option(
       '-m', '--markdown', action='store_true',
       help='Use markdown-friendly output (overrides --output-format '
            'and --output-format-heading)')
@@ -825,19 +922,21 @@
   if options.markdown:
     options.output_format = ' * [{title}]({url})'
     options.output_format_heading = '### {heading} ###'
+    options.output_format_no_url = ' * {title}'
   logging.info('Searching for activity by %s', options.user)
   logging.info('Using range %s to %s', options.begin, options.end)
 
   my_activity = MyActivity(options)
 
-  if not (options.changes or options.reviews or options.issues):
+  if not (options.changes or options.reviews or options.issues or
+          options.changes_by_issue):
     options.changes = True
     options.issues = True
     options.reviews = True
 
   # First do any required authentication so none of the user interaction has to
   # wait for actual work.
-  if options.changes:
+  if options.changes or options.changes_by_issue:
     my_activity.auth_for_changes()
   if options.reviews:
     my_activity.auth_for_reviews()
@@ -845,12 +944,14 @@
   logging.info('Looking up activity.....')
 
   try:
-    if options.changes:
+    if options.changes or options.changes_by_issue:
       my_activity.get_changes()
     if options.reviews:
       my_activity.get_reviews()
-    if options.issues:
+    if options.issues or options.changes_by_issue:
       my_activity.get_issues()
+    if not options.no_referenced_issues:
+      my_activity.get_referenced_issues()
   except auth.AuthenticationError as e:
     logging.error('auth.AuthenticationError: %s', e)
 
@@ -866,9 +967,15 @@
     if options.json:
       my_activity.dump_json()
     else:
-      my_activity.print_changes()
-      my_activity.print_reviews()
-      my_activity.print_issues()
+      if options.changes:
+        my_activity.print_changes()
+      if options.reviews:
+        my_activity.print_reviews()
+      if options.issues:
+        my_activity.print_issues()
+      if options.changes_by_issue:
+        my_activity.print_changes_by_issue(
+            options.skip_own_issues_without_changes)
   finally:
     if output_file:
       logging.info('Done printing to file.')