• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 ">"(&gt;) symbols.
287  # Should only parse CL link corresponding the revision found by the bisect.
288  link_pattern = (r'(?<!&gt;\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