1# Copyright 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Provides the web interface for filing a bug on the issue tracker."""
6
7import json
8import logging
9
10from google.appengine.api import app_identity
11from google.appengine.api import urlfetch
12from google.appengine.api import users
13from google.appengine.ext import ndb
14
15from dashboard import auto_bisect
16from dashboard import issue_tracker_service
17from dashboard import oauth2_decorator
18from dashboard import request_handler
19from dashboard import utils
20from dashboard.models import alert
21from dashboard.models import bug_data
22from dashboard.models import bug_label_patterns
23
24# A list of bug labels to suggest for all performance regression bugs.
25_DEFAULT_LABELS = [
26    'Type-Bug-Regression',
27    'Pri-2',
28    'Performance-Sheriff'
29]
30_OMAHA_PROXY_URL = 'https://omahaproxy.appspot.com/all.json'
31
32
33class FileBugHandler(request_handler.RequestHandler):
34  """Uses oauth2 to file a new bug with a set of alerts."""
35
36  def post(self):
37    """A POST request for this endpoint is the same as a GET request."""
38    self.get()
39
40  @oauth2_decorator.DECORATOR.oauth_required
41  def get(self):
42    """Either shows the form to file a bug, or if filled in, files the bug.
43
44    The form to file a bug is popped up from the triage-dialog polymer element.
45    The default summary, description and label strings are constructed there.
46
47    Request parameters:
48      summary: Bug summary string.
49      description: Bug full description string.
50      owner: Bug owner email address.
51      keys: Comma-separated Alert keys in urlsafe format.
52
53    Outputs:
54      HTML, using the template 'bug_result.html'.
55    """
56    if not utils.IsValidSheriffUser():
57      # TODO(qyearsley): Simplify this message (after a couple months).
58      self.RenderHtml('bug_result.html', {
59          'error': ('You must be logged in with a chromium.org account '
60                    'in order to file bugs here! This is the case ever '
61                    'since we switched to the Monorail issue tracker. '
62                    'Note, viewing internal data should work for Googlers '
63                    'that are logged in with the Chromium accounts. See '
64                    'https://github.com/catapult-project/catapult/issues/2042')
65      })
66      return
67
68    summary = self.request.get('summary')
69    description = self.request.get('description')
70    labels = self.request.get_all('label')
71    components = self.request.get_all('component')
72    keys = self.request.get('keys')
73    if not keys:
74      self.RenderHtml('bug_result.html', {
75          'error': 'No alerts specified to add bugs to.'
76      })
77      return
78
79    if self.request.get('finish'):
80      self._CreateBug(summary, description, labels, components, keys)
81    else:
82      self._ShowBugDialog(summary, description, keys)
83
84  def _ShowBugDialog(self, summary, description, urlsafe_keys):
85    """Sends a HTML page with a form for filing the bug.
86
87    Args:
88      summary: The default bug summary string.
89      description: The default bug description string.
90      urlsafe_keys: Comma-separated Alert keys in urlsafe format.
91    """
92    alert_keys = [ndb.Key(urlsafe=k) for k in urlsafe_keys.split(',')]
93    labels, components = _FetchLabelsAndComponents(alert_keys)
94    self.RenderHtml('bug_result.html', {
95        'bug_create_form': True,
96        'keys': urlsafe_keys,
97        'summary': summary,
98        'description': description,
99        'labels': labels,
100        'components': components,
101        'owner': users.get_current_user(),
102    })
103
104  def _CreateBug(self, summary, description, labels, components, urlsafe_keys):
105    """Creates a bug, associates it with the alerts, sends a HTML response.
106
107    Args:
108      summary: The new bug summary string.
109      description: The new bug description string.
110      labels: List of label strings for the new bug.
111      components: List of component strings for the new bug.
112      urlsafe_keys: Comma-separated alert keys in urlsafe format.
113    """
114    alert_keys = [ndb.Key(urlsafe=k) for k in urlsafe_keys.split(',')]
115    alerts = ndb.get_multi(alert_keys)
116
117    if not description:
118      description = 'See the link to graphs below.'
119
120    milestone_label = _MilestoneLabel(alerts)
121    if milestone_label:
122      labels.append(milestone_label)
123
124    # Only project members (@chromium.org accounts) can be owners of bugs.
125    owner = self.request.get('owner')
126    if owner and not owner.endswith('@chromium.org'):
127      self.RenderHtml('bug_result.html', {
128          'error': 'Owner email address must end with @chromium.org.'
129      })
130      return
131
132    http = oauth2_decorator.DECORATOR.http()
133    service = issue_tracker_service.IssueTrackerService(http=http)
134
135    bug_id = service.NewBug(
136        summary, description, labels=labels, components=components, owner=owner)
137    if not bug_id:
138      self.RenderHtml('bug_result.html', {'error': 'Error creating bug!'})
139      return
140
141    bug_data.Bug(id=bug_id).put()
142    for alert_entity in alerts:
143      alert_entity.bug_id = bug_id
144    ndb.put_multi(alerts)
145
146    comment_body = _AdditionalDetails(bug_id, alerts)
147    service.AddBugComment(bug_id, comment_body)
148
149    template_params = {'bug_id': bug_id}
150    if all(k.kind() == 'Anomaly' for k in alert_keys):
151      bisect_result = auto_bisect.StartNewBisectForBug(bug_id)
152      if 'error' in bisect_result:
153        template_params['bisect_error'] = bisect_result['error']
154      else:
155        template_params.update(bisect_result)
156    self.RenderHtml('bug_result.html', template_params)
157
158
159def _AdditionalDetails(bug_id, alerts):
160  """Returns a message with additional information to add to a bug."""
161  base_url = '%s/group_report' % _GetServerURL()
162  bug_page_url = '%s?bug_id=%s' % (base_url, bug_id)
163  alerts_url = '%s?keys=%s' % (base_url, _UrlsafeKeys(alerts))
164  comment = 'All graphs for this bug:\n  %s\n\n' % bug_page_url
165  comment += 'Original alerts at time of bug-filing:\n  %s\n' % alerts_url
166  bot_names = alert.GetBotNamesFromAlerts(alerts)
167  if bot_names:
168    comment += '\n\nBot(s) for this bug\'s original alert(s):\n\n'
169    comment += '\n'.join(sorted(bot_names))
170  else:
171    comment += '\nCould not extract bot names from the list of alerts.'
172  return comment
173
174
175def _GetServerURL():
176  return 'https://' + app_identity.get_default_version_hostname()
177
178
179def _UrlsafeKeys(alerts):
180  return ','.join(a.key.urlsafe() for a in alerts)
181
182
183def _FetchLabelsAndComponents(alert_keys):
184  """Fetches a list of bug labels and components for the given Alert keys."""
185  labels = set(_DEFAULT_LABELS)
186  components = set()
187  alerts = ndb.get_multi(alert_keys)
188  if any(a.internal_only for a in alerts):
189    # This is a Chrome-specific behavior, and should ideally be made
190    # more general (maybe there should be a list in datastore of bug
191    # labels to add for internal bugs).
192    labels.add('Restrict-View-Google')
193  for test in {a.test for a in alerts}:
194    labels_components = bug_label_patterns.GetBugLabelsForTest(test)
195    for item in labels_components:
196      if item.startswith('Cr-'):
197        components.add(item.replace('Cr-', '').replace('-', '>'))
198      else:
199        labels.add(item)
200  return labels, components
201
202
203def _MilestoneLabel(alerts):
204  """Returns a milestone label string, or None."""
205  revisions = [a.start_revision for a in alerts if hasattr(a, 'start_revision')]
206  if not revisions:
207    return None
208  start_revision = min(revisions)
209  try:
210    milestone = _GetMilestoneForRevision(start_revision)
211  except KeyError:
212    logging.error('List of versions not in the expected format')
213  if not milestone:
214    return None
215  logging.info('Matched rev %s to milestone %s.', start_revision, milestone)
216  return 'M-%d' % milestone
217
218
219def _GetMilestoneForRevision(revision):
220  """Finds the oldest milestone for a given revision from OmahaProxy.
221
222  The purpose of this function is to resolve the milestone that would be blocked
223  by a suspected regression. We do this by locating in the list of current
224  versions, regardless of platform and channel, all the version strings (e.g.
225  36.0.1234.56) that match revisions (commit positions) later than the earliest
226  possible start_revision of the suspected regression; we then parse out the
227  first numeric part of such strings, assume it to be the corresponding
228  milestone, and return the lowest one in the set.
229
230  Args:
231    revision: An integer or string containing an integer.
232
233  Returns:
234    An integer representing the lowest milestone matching the given revision or
235    the highest milestone if the given revision exceeds all defined milestones.
236    Note that the default is 0 when no milestones at all are found. If the
237    given revision is None, then None is returned.
238  """
239  if revision is None:
240    return None
241  milestones = set()
242  default_milestone = 0
243  all_versions = _GetAllCurrentVersionsFromOmahaProxy()
244  for os in all_versions:
245    for version in os['versions']:
246      try:
247        milestone = int(version['current_version'].split('.')[0])
248        version_commit = version.get('branch_base_position')
249        if version_commit and int(revision) < int(version_commit):
250          milestones.add(milestone)
251        if milestone > default_milestone:
252          default_milestone = milestone
253      except ValueError:
254        # Sometimes 'N/A' is given. We ignore these entries.
255        logging.warn('Could not cast one of: %s, %s, %s as an int',
256                     revision, version['branch_base_position'],
257                     version['current_version'].split('.')[0])
258  if milestones:
259    return min(milestones)
260  return default_milestone
261
262
263def _GetAllCurrentVersionsFromOmahaProxy():
264  """Retrieves a the list current versions from OmahaProxy and parses it."""
265  try:
266    response = urlfetch.fetch(_OMAHA_PROXY_URL)
267    if response.status_code == 200:
268      return json.loads(response.content)
269  except urlfetch.Error:
270    logging.error('Error pulling list of current versions (omahaproxy).')
271  except ValueError:
272    logging.error('OmahaProxy did not return valid JSON.')
273  return []
274