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 an endpoint and web interface for associating alerts with bug.""" 6 7import re 8 9from google.appengine.api import users 10from google.appengine.ext import ndb 11 12from dashboard import issue_tracker_service 13from dashboard import oauth2_decorator 14from dashboard import request_handler 15from dashboard import utils 16from dashboard.models import anomaly 17from dashboard.models import stoppage_alert 18 19 20class AssociateAlertsHandler(request_handler.RequestHandler): 21 """Associates alerts with a bug.""" 22 23 def post(self): 24 """POST is the same as GET for this endpoint.""" 25 self.get() 26 27 @oauth2_decorator.DECORATOR.oauth_required 28 def get(self): 29 """Response handler for the page used to group an alert with a bug. 30 31 Request parameters: 32 bug_id: Bug ID number, as a string (when submitting the form). 33 keys: Comma-separated alert keys in urlsafe format. 34 confirm: If non-empty, associate alerts with a bug ID even if 35 it appears that the alerts already associated with that bug 36 have a non-overlapping revision range. 37 38 Outputs: 39 HTML with result. 40 """ 41 if not utils.IsValidSheriffUser(): 42 user = users.get_current_user() 43 self.ReportError('User "%s" not authorized.' % user, status=403) 44 return 45 46 urlsafe_keys = self.request.get('keys') 47 if not urlsafe_keys: 48 self.RenderHtml('bug_result.html', { 49 'error': 'No alerts specified to add bugs to.'}) 50 return 51 52 is_confirmed = bool(self.request.get('confirm')) 53 bug_id = self.request.get('bug_id') 54 if bug_id: 55 self._AssociateAlertsWithBug(bug_id, urlsafe_keys, is_confirmed) 56 else: 57 self._ShowCommentDialog(urlsafe_keys) 58 59 def _ShowCommentDialog(self, urlsafe_keys): 60 """Sends a HTML page with a form for selecting a bug number. 61 62 Args: 63 urlsafe_keys: Comma-separated Alert keys in urlsafe format. 64 """ 65 # Get information about Alert entities and related TestMetadata entities, 66 # so that they can be compared with recent bugs. 67 alert_keys = [ndb.Key(urlsafe=k) for k in urlsafe_keys.split(',')] 68 alert_entities = ndb.get_multi(alert_keys) 69 ranges = [(a.start_revision, a.end_revision) for a in alert_entities] 70 71 # Mark bugs that have overlapping revision ranges as potentially relevant. 72 # On the alerts page, alerts are only highlighted if the revision range 73 # overlaps with the revision ranges for all of the selected alerts; the 74 # same thing is done here. 75 bugs = self._FetchBugs() 76 for bug in bugs: 77 this_range = _RevisionRangeFromSummary(bug['summary']) 78 bug['relevant'] = all(_RangesOverlap(this_range, r) for r in ranges) 79 80 self.RenderHtml('bug_result.html', { 81 'bug_associate_form': True, 82 'keys': urlsafe_keys, 83 'bugs': bugs 84 }) 85 86 def _FetchBugs(self): 87 http = oauth2_decorator.DECORATOR.http() 88 issue_tracker = issue_tracker_service.IssueTrackerService(http) 89 response = issue_tracker.List( 90 q='opened-after:today-5', label='Type-Bug-Regression,Performance', 91 sort='-id') 92 return response.get('items', []) if response else [] 93 94 def _AssociateAlertsWithBug(self, bug_id, urlsafe_keys, is_confirmed): 95 """Sets the bug ID for a set of alerts. 96 97 This is done after the user enters and submits a bug ID. 98 99 Args: 100 bug_id: Bug ID number, as a string. 101 urlsafe_keys: Comma-separated Alert keys in urlsafe format. 102 is_confirmed: Whether the user has confirmed that they really want 103 to associate the alerts with a bug even if it appears that the 104 revision ranges don't overlap. 105 """ 106 # Validate bug ID. 107 try: 108 bug_id = int(bug_id) 109 except ValueError: 110 self.RenderHtml( 111 'bug_result.html', 112 {'error': 'Invalid bug ID "%s".' % str(bug_id)}) 113 return 114 115 # Get Anomaly entities and related TestMetadata entities. 116 alert_keys = [ndb.Key(urlsafe=k) for k in urlsafe_keys.split(',')] 117 alert_entities = ndb.get_multi(alert_keys) 118 119 if not is_confirmed: 120 warning_msg = self._VerifyAnomaliesOverlap(alert_entities, bug_id) 121 if warning_msg: 122 self._ShowConfirmDialog('associate_alerts', warning_msg, { 123 'bug_id': bug_id, 124 'keys': urlsafe_keys, 125 }) 126 return 127 128 for a in alert_entities: 129 a.bug_id = bug_id 130 ndb.put_multi(alert_entities) 131 132 self.RenderHtml('bug_result.html', {'bug_id': bug_id}) 133 134 def _VerifyAnomaliesOverlap(self, alerts, bug_id): 135 """Checks whether the alerts' revision ranges intersect. 136 137 Args: 138 alerts: A list of Alert entities to verify. 139 bug_id: Bug ID number. 140 141 Returns: 142 A string with warning message, or None if there's no warning. 143 """ 144 if not utils.MinimumAlertRange(alerts): 145 return 'Selected alerts do not have overlapping revision range.' 146 else: 147 anomalies_with_bug = anomaly.Anomaly.query( 148 anomaly.Anomaly.bug_id == bug_id).fetch() 149 stoppage_alerts_with_bug = stoppage_alert.StoppageAlert.query( 150 stoppage_alert.StoppageAlert.bug_id == bug_id).fetch() 151 alerts_with_bug = anomalies_with_bug + stoppage_alerts_with_bug 152 153 if not alerts_with_bug: 154 return None 155 if not utils.MinimumAlertRange(alerts_with_bug): 156 return ('Alerts in bug %s do not have overlapping revision ' 157 'range.' % bug_id) 158 elif not utils.MinimumAlertRange(alerts + alerts_with_bug): 159 return ('Selected alerts do not have overlapping revision ' 160 'range with alerts in bug %s.' % bug_id) 161 return None 162 163 def _ShowConfirmDialog(self, handler, message, parameters): 164 """Sends a HTML page with a form to confirm an action. 165 166 Args: 167 handler: Name of URL handler to submit confirm dialog. 168 message: Confirmation message. 169 parameters: Dictionary of request parameters to submit with confirm 170 dialog. 171 """ 172 self.RenderHtml('bug_result.html', { 173 'confirmation_required': True, 174 'handler': handler, 175 'message': message, 176 'parameters': parameters or {} 177 }) 178 179 180def _RevisionRangeFromSummary(summary): 181 """Uses regex to extract revision range from bug a summary string. 182 183 Note: Information such as test path and revision range for a bug could 184 also be gotten by querying the datastore for Anomaly entities for 185 each bug ID. However, these queries might be relatively costly. Also, 186 it is acceptable if the information extracted isn't 100% accurate, 187 because it is only used to make a list of bugs for convenience. 188 189 Note: The format of the summary is determined by the triage-dialog element. 190 191 Args: 192 summary: The bug summary string. 193 194 Returns: 195 A pair of revision numbers (start, end), or None. 196 """ 197 match = re.match(r'.* (\d+):(\d+)$', summary) 198 if match: 199 start, end = match.groups() 200 # Since start and end matched '\d+', we know they can be parsed as ints. 201 return (int(start), int(end)) 202 return None 203 204 205def _RangesOverlap(range1, range2): 206 """Checks whether two revision ranges overlap. 207 208 Note, sharing an endpoint is considered overlap for this function. 209 210 Args: 211 range1: A pair of integers (start, end). 212 range2: Another pair of integers. 213 214 Returns: 215 True if there is any overlap, False otherwise. 216 """ 217 if not range1 or not range2: 218 return False 219 return range1[0] <= range2[1] and range1[1] >= range2[0] 220