1# Copyright 2016 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"""URL endpoint for a cron job to update bugs after bisects.""" 6 7import datetime 8import json 9import logging 10import re 11import traceback 12 13from google.appengine.api import mail 14from google.appengine.ext import ndb 15 16from dashboard import bisect_fyi 17from dashboard import bisect_report 18from dashboard import datastore_hooks 19from dashboard import email_template 20from dashboard import issue_tracker_service 21from dashboard import layered_cache 22from dashboard import quick_logger 23from dashboard import request_handler 24from dashboard import utils 25from dashboard.models import anomaly 26from dashboard.models import bug_data 27from dashboard.models import try_job 28 29COMPLETED, FAILED, PENDING, ABORTED = ('completed', 'failed', 'pending', 30 'aborted') 31 32_COMMIT_HASH_CACHE_KEY = 'commit_hash_%s' 33 34# Amount of time to pass before deleting a try job. 35_STALE_TRYJOB_DELTA = datetime.timedelta(days=7) 36 37_AUTO_ASSIGN_MSG = """ 38=== Auto-CCing suspected CL author %(author)s === 39 40Hi %(author)s, the bisect results pointed to your CL below as possibly 41causing a regression. Please have a look at this info and see whether 42your CL be related. 43 44""" 45 46_CONFIDENCE_LEVEL_TO_CC_AUTHOR = 95 47 48 49class BugUpdateFailure(Exception): 50 pass 51 52 53class UpdateBugWithResultsHandler(request_handler.RequestHandler): 54 """URL endpoint for a cron job to update bugs after bisects.""" 55 56 def get(self): 57 """The get handler method is called from a cron job. 58 59 It expects no parameters and has no output. It checks all current bisect try 60 jobs and send comments to an issue on the issue tracker if a bisect job has 61 completed. 62 """ 63 credentials = utils.ServiceAccountCredentials() 64 issue_tracker = issue_tracker_service.IssueTrackerService( 65 additional_credentials=credentials) 66 67 # Set privilege so we can also fetch internal try_job entities. 68 datastore_hooks.SetPrivilegedRequest() 69 70 jobs_to_check = try_job.TryJob.query( 71 try_job.TryJob.status.IN(['started', 'pending'])).fetch() 72 all_successful = True 73 74 for job in jobs_to_check: 75 try: 76 _CheckJob(job, issue_tracker) 77 except Exception as e: # pylint: disable=broad-except 78 logging.error('Caught Exception %s: %s\n%s', 79 type(e).__name__, e, traceback.format_exc()) 80 all_successful = False 81 82 if all_successful: 83 utils.TickMonitoringCustomMetric('UpdateBugWithResults') 84 85 86def _CheckJob(job, issue_tracker): 87 """Checks whether a try job is finished and updates a bug if applicable. 88 89 This method returns nothing, but it may log errors. 90 91 Args: 92 job: A TryJob entity, which represents one bisect try job. 93 issue_tracker: An issue_tracker_service.IssueTrackerService instance. 94 """ 95 if _IsStale(job): 96 job.SetStaled() 97 # TODO(chrisphan): Add a staled TryJob log. 98 # TODO(chrisphan): Do we want to send a FYI Bisect email here? 99 return 100 101 results_data = job.results_data 102 if not results_data or results_data['status'] not in [COMPLETED, FAILED]: 103 return 104 105 if job.job_type == 'perf-try': 106 _SendPerfTryJobEmail(job) 107 elif job.job_type == 'bisect-fyi': 108 _CheckFYIBisectJob(job, issue_tracker) 109 else: 110 _CheckBisectJob(job, issue_tracker) 111 112 if results_data['status'] == COMPLETED: 113 job.SetCompleted() 114 else: 115 job.SetFailed() 116 117 118def _CheckBisectJob(job, issue_tracker): 119 results_data = job.results_data 120 has_partial_result = ('revision_data' in results_data and 121 results_data['revision_data']) 122 if results_data['status'] == FAILED and not has_partial_result: 123 return 124 _PostResult(job, issue_tracker) 125 126 127def _CheckFYIBisectJob(job, issue_tracker): 128 try: 129 _PostResult(job, issue_tracker) 130 error_message = bisect_fyi.VerifyBisectFYIResults(job) 131 if not bisect_fyi.IsBugUpdated(job, issue_tracker): 132 error_message += '\nFailed to update bug with bisect results.' 133 except BugUpdateFailure as e: 134 error_message = 'Failed to update bug with bisect results: %s' % e 135 if job.results_data['status'] == FAILED or error_message: 136 _SendFYIBisectEmail(job, error_message) 137 138 139def _SendPerfTryJobEmail(job): 140 """Sends an email to the user who started the perf try job.""" 141 if not job.email: 142 return 143 email_report = email_template.GetPerfTryJobEmailReport(job) 144 if not email_report: 145 return 146 mail.send_mail(sender='gasper-alerts@google.com', 147 to=job.email, 148 subject=email_report['subject'], 149 body=email_report['body'], 150 html=email_report['html']) 151 152 153def _PostResult(job, issue_tracker): 154 """Posts bisect results on issue tracker.""" 155 # From the results, get the list of people to CC (if applicable), the bug 156 # to merge into (if applicable) and the commit hash cache key, which 157 # will be used below. 158 if job.bug_id < 0: 159 return 160 161 results_data = job.results_data 162 authors_to_cc = [] 163 commit_cache_key = _GetCommitHashCacheKey(results_data) 164 165 merge_issue = layered_cache.GetExternal(commit_cache_key) 166 if not merge_issue: 167 authors_to_cc = _GetAuthorsToCC(results_data) 168 169 comment = bisect_report.GetReport(job) 170 171 # Add a friendly message to author of culprit CL. 172 owner = None 173 if authors_to_cc: 174 comment = '%s%s' % (_AUTO_ASSIGN_MSG % {'author': authors_to_cc[0]}, 175 comment) 176 owner = authors_to_cc[0] 177 # Set restrict view label if the bisect results are internal only. 178 labels = ['Restrict-View-Google'] if job.internal_only else None 179 comment_added = issue_tracker.AddBugComment( 180 job.bug_id, comment, cc_list=authors_to_cc, merge_issue=merge_issue, 181 labels=labels, owner=owner) 182 if not comment_added: 183 raise BugUpdateFailure('Failed to update bug %s with comment %s' 184 % (job.bug_id, comment)) 185 186 logging.info('Updated bug %s with results from %s', 187 job.bug_id, job.rietveld_issue_id) 188 189 if merge_issue: 190 _MapAnomaliesToMergeIntoBug(merge_issue, job.bug_id) 191 # Mark the duplicate bug's Bug entity status as closed so that 192 # it doesn't get auto triaged. 193 bug = ndb.Key('Bug', job.bug_id).get() 194 if bug: 195 bug.status = bug_data.BUG_STATUS_CLOSED 196 bug.put() 197 198 # Cache the commit info and bug ID to datastore when there is no duplicate 199 # issue that this issue is getting merged into. This has to be done only 200 # after the issue is updated successfully with bisect information. 201 if commit_cache_key and not merge_issue: 202 layered_cache.SetExternal(commit_cache_key, str(job.bug_id), 203 days_to_keep=30) 204 logging.info('Cached bug id %s and commit info %s in the datastore.', 205 job.bug_id, commit_cache_key) 206 207 208def _IsStale(job): 209 if not job.last_ran_timestamp: 210 return False 211 time_since_last_ran = datetime.datetime.now() - job.last_ran_timestamp 212 return time_since_last_ran > _STALE_TRYJOB_DELTA 213 214 215def _MapAnomaliesToMergeIntoBug(dest_bug_id, source_bug_id): 216 """Maps anomalies from source bug to destination bug. 217 218 Args: 219 dest_bug_id: Merge into bug (base bug) number. 220 source_bug_id: The bug to be merged. 221 """ 222 query = anomaly.Anomaly.query( 223 anomaly.Anomaly.bug_id == int(source_bug_id)) 224 anomalies = query.fetch() 225 for anomaly_entity in anomalies: 226 anomaly_entity.bug_id = int(dest_bug_id) 227 ndb.put_multi(anomalies) 228 229 230def _GetCommitHashCacheKey(results_data): 231 """Gets a commit hash cache key for the given bisect results output. 232 233 Args: 234 results_data: Bisect results data. 235 236 Returns: 237 A string to use as a layered_cache key, or None if we don't want 238 to merge any bugs based on this bisect result. 239 """ 240 if results_data.get('culprit_data'): 241 return _COMMIT_HASH_CACHE_KEY % results_data['culprit_data']['cl'] 242 return None 243 244 245def _GetAuthorsToCC(results_data): 246 """Makes a list of email addresses that we want to CC on the bug. 247 248 TODO(qyearsley): Make sure that the bisect result bot doesn't cc 249 non-googlers on Restrict-View-Google bugs. This might be done by making 250 a request for labels for the bug (or by making a request for alerts in 251 the datastore for the bug id and checking the internal-only property). 252 253 Args: 254 results_data: Bisect results data. 255 256 Returns: 257 A list of email addresses, possibly empty. 258 """ 259 if results_data.get('score') < _CONFIDENCE_LEVEL_TO_CC_AUTHOR: 260 return [] 261 culprit_data = results_data.get('culprit_data') 262 if not culprit_data: 263 return [] 264 emails = [culprit_data['email']] if culprit_data['email'] else [] 265 emails.extend(_GetReviewersFromCulpritData(culprit_data)) 266 return emails 267 268 269def _GetReviewersFromCulpritData(culprit_data): 270 """Parse bisect log and gets reviewers email addresses from Rietveld issue. 271 272 Note: This method doesn't get called when bisect reports multiple CLs by 273 different authors, but will get called when there are multiple CLs by the 274 same owner. 275 276 Args: 277 culprit_data: Bisect results culprit data. 278 279 Returns: 280 List of email addresses from the committed CL. 281 """ 282 283 reviewer_list = [] 284 revisions_links = culprit_data['revisions_links'] 285 # Sometime revision page content consist of multiple "Review URL" strings 286 # due to some reverted CLs, such CLs are prefixed with ">"(>) symbols. 287 # Should only parse CL link corresponding the revision found by the bisect. 288 link_pattern = (r'(?<!>\s)Review URL: <a href=[\'"]' 289 r'https://codereview.chromium.org/(\d+)[\'"].*>') 290 for link in revisions_links: 291 # Fetch the commit links in order to get codereview link. 292 response = utils.FetchURL(link) 293 if not response: 294 continue 295 rietveld_issue_ids = re.findall(link_pattern, response.content) 296 for issue_id in rietveld_issue_ids: 297 # Fetch codereview link, and get reviewer email addresses from the 298 # response JSON. 299 issue_response = utils.FetchURL( 300 'https://codereview.chromium.org/api/%s' % issue_id) 301 if not issue_response: 302 continue 303 issue_data = json.loads(issue_response.content) 304 reviewer_list.extend([str(item) for item in issue_data['reviewers']]) 305 return reviewer_list 306 307 308def _SendFYIBisectEmail(job, message): 309 """Sends an email to auto-bisect-team about FYI bisect results.""" 310 email_data = email_template.GetBisectFYITryJobEmailReport(job, message) 311 mail.send_mail(sender='gasper-alerts@google.com', 312 to='auto-bisect-team@google.com', 313 subject=email_data['subject'], 314 body=email_data['body'], 315 html=email_data['html']) 316 317 318def UpdateQuickLog(job): 319 if not job.bug_id or job.bug_id < 0: 320 return 321 report = bisect_report.GetReport(job) 322 if not report: 323 logging.error('Bisect report returns empty for job id %s, bug_id %s.', 324 job.key.id(), job.bug_id) 325 return 326 formatter = quick_logger.Formatter() 327 logger = quick_logger.QuickLogger('bisect_result', job.bug_id, formatter) 328 if job.log_record_id: 329 logger.Log(report, record_id=job.log_record_id) 330 logger.Save() 331 else: 332 job.log_record_id = logger.Log(report) 333 logger.Save() 334 job.put() 335