blob: 1086831dd485a50a0703cc716b139574c0b40443 [file] [log] [blame]
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Provides an endpoint and web interface for associating alerts with bug."""
import re
from google.appengine.api import users
from google.appengine.ext import ndb
from dashboard import issue_tracker_service
from dashboard import oauth2_decorator
from dashboard import request_handler
from dashboard import utils
from dashboard.models import anomaly
from dashboard.models import stoppage_alert
class AssociateAlertsHandler(request_handler.RequestHandler):
"""Associates alerts with a bug."""
def post(self):
"""POST is the same as GET for this endpoint."""
self.get()
@oauth2_decorator.DECORATOR.oauth_required
def get(self):
"""Response handler for the page used to group an alert with a bug.
Request parameters:
bug_id: Bug ID number, as a string (when submitting the form).
keys: Comma-separated alert keys in urlsafe format.
confirm: If non-empty, associate alerts with a bug ID even if
it appears that the alerts already associated with that bug
have a non-overlapping revision range.
Outputs:
HTML with result.
"""
if not utils.IsValidSheriffUser():
user = users.get_current_user()
self.ReportError('User "%s" not authorized.' % user, status=403)
return
urlsafe_keys = self.request.get('keys')
if not urlsafe_keys:
self.RenderHtml('bug_result.html', {
'error': 'No alerts specified to add bugs to.'})
return
is_confirmed = bool(self.request.get('confirm'))
bug_id = self.request.get('bug_id')
if bug_id:
self._AssociateAlertsWithBug(bug_id, urlsafe_keys, is_confirmed)
else:
self._ShowCommentDialog(urlsafe_keys)
def _ShowCommentDialog(self, urlsafe_keys):
"""Sends a HTML page with a form for selecting a bug number.
Args:
urlsafe_keys: Comma-separated Alert keys in urlsafe format.
"""
# Get information about Alert entities and related TestMetadata entities,
# so that they can be compared with recent bugs.
alert_keys = [ndb.Key(urlsafe=k) for k in urlsafe_keys.split(',')]
alert_entities = ndb.get_multi(alert_keys)
ranges = [(a.start_revision, a.end_revision) for a in alert_entities]
# Mark bugs that have overlapping revision ranges as potentially relevant.
# On the alerts page, alerts are only highlighted if the revision range
# overlaps with the revision ranges for all of the selected alerts; the
# same thing is done here.
bugs = self._FetchBugs()
for bug in bugs:
this_range = _RevisionRangeFromSummary(bug['summary'])
bug['relevant'] = all(_RangesOverlap(this_range, r) for r in ranges)
self.RenderHtml('bug_result.html', {
'bug_associate_form': True,
'keys': urlsafe_keys,
'bugs': bugs
})
def _FetchBugs(self):
http = oauth2_decorator.DECORATOR.http()
issue_tracker = issue_tracker_service.IssueTrackerService(http)
response = issue_tracker.List(
q='opened-after:today-5', label='Type-Bug-Regression,Performance',
sort='-id')
return response.get('items', []) if response else []
def _AssociateAlertsWithBug(self, bug_id, urlsafe_keys, is_confirmed):
"""Sets the bug ID for a set of alerts.
This is done after the user enters and submits a bug ID.
Args:
bug_id: Bug ID number, as a string.
urlsafe_keys: Comma-separated Alert keys in urlsafe format.
is_confirmed: Whether the user has confirmed that they really want
to associate the alerts with a bug even if it appears that the
revision ranges don't overlap.
"""
# Validate bug ID.
try:
bug_id = int(bug_id)
except ValueError:
self.RenderHtml(
'bug_result.html',
{'error': 'Invalid bug ID "%s".' % str(bug_id)})
return
# Get Anomaly entities and related TestMetadata entities.
alert_keys = [ndb.Key(urlsafe=k) for k in urlsafe_keys.split(',')]
alert_entities = ndb.get_multi(alert_keys)
if not is_confirmed:
warning_msg = self._VerifyAnomaliesOverlap(alert_entities, bug_id)
if warning_msg:
self._ShowConfirmDialog('associate_alerts', warning_msg, {
'bug_id': bug_id,
'keys': urlsafe_keys,
})
return
for a in alert_entities:
a.bug_id = bug_id
ndb.put_multi(alert_entities)
self.RenderHtml('bug_result.html', {'bug_id': bug_id})
def _VerifyAnomaliesOverlap(self, alerts, bug_id):
"""Checks whether the alerts' revision ranges intersect.
Args:
alerts: A list of Alert entities to verify.
bug_id: Bug ID number.
Returns:
A string with warning message, or None if there's no warning.
"""
if not utils.MinimumAlertRange(alerts):
return 'Selected alerts do not have overlapping revision range.'
else:
anomalies_with_bug = anomaly.Anomaly.query(
anomaly.Anomaly.bug_id == bug_id).fetch()
stoppage_alerts_with_bug = stoppage_alert.StoppageAlert.query(
stoppage_alert.StoppageAlert.bug_id == bug_id).fetch()
alerts_with_bug = anomalies_with_bug + stoppage_alerts_with_bug
if not alerts_with_bug:
return None
if not utils.MinimumAlertRange(alerts_with_bug):
return ('Alerts in bug %s do not have overlapping revision '
'range.' % bug_id)
elif not utils.MinimumAlertRange(alerts + alerts_with_bug):
return ('Selected alerts do not have overlapping revision '
'range with alerts in bug %s.' % bug_id)
return None
def _ShowConfirmDialog(self, handler, message, parameters):
"""Sends a HTML page with a form to confirm an action.
Args:
handler: Name of URL handler to submit confirm dialog.
message: Confirmation message.
parameters: Dictionary of request parameters to submit with confirm
dialog.
"""
self.RenderHtml('bug_result.html', {
'confirmation_required': True,
'handler': handler,
'message': message,
'parameters': parameters or {}
})
def _RevisionRangeFromSummary(summary):
"""Uses regex to extract revision range from bug a summary string.
Note: Information such as test path and revision range for a bug could
also be gotten by querying the datastore for Anomaly entities for
each bug ID. However, these queries might be relatively costly. Also,
it is acceptable if the information extracted isn't 100% accurate,
because it is only used to make a list of bugs for convenience.
Note: The format of the summary is determined by the triage-dialog element.
Args:
summary: The bug summary string.
Returns:
A pair of revision numbers (start, end), or None.
"""
match = re.match(r'.* (\d+):(\d+)$', summary)
if match:
start, end = match.groups()
# Since start and end matched '\d+', we know they can be parsed as ints.
return (int(start), int(end))
return None
def _RangesOverlap(range1, range2):
"""Checks whether two revision ranges overlap.
Note, sharing an endpoint is considered overlap for this function.
Args:
range1: A pair of integers (start, end).
range2: Another pair of integers.
Returns:
True if there is any overlap, False otherwise.
"""
if not range1 or not range2:
return False
return range1[0] <= range2[1] and range1[1] >= range2[0]