blob: 4d52e1c3021b951cbcc43cdc40e9e796cc8890b1 [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 a layer of abstraction for the issue tracker API."""
import json
import logging
from apiclient import discovery
from apiclient import errors
_DISCOVERY_URI = ('https://monorail-prod.appspot.com'
'/_ah/api/discovery/v1/apis/{api}/{apiVersion}/rest')
class IssueTrackerService(object):
"""Class for updating bug issues."""
def __init__(self, http):
"""Initializes an object for adding and updating bugs on the issue tracker.
This object can be re-used to make multiple requests without calling
apliclient.discovery.build multiple times.
This class makes requests to the Monorail API.
API explorer: https://goo.gl/xWd0dX
Args:
http: A Http object that requests will be made through; this should be an
Http object that's already authenticated via OAuth2.
"""
self._service = discovery.build(
'monorail', 'v1', discoveryServiceUrl=_DISCOVERY_URI, http=http)
def AddBugComment(self, bug_id, comment, status=None, cc_list=None,
merge_issue=None, labels=None, owner=None, send_email=True):
"""Adds a comment with the bisect results to the given bug.
Args:
bug_id: Bug ID of the issue to update.
comment: Bisect results information.
status: A string status for bug, e.g. Assigned, Duplicate, WontFix, etc.
cc_list: List of email addresses of users to add to the CC list.
merge_issue: ID of the issue to be merged into; specifying this option
implies that the status should be "Duplicate".
labels: List of labels for bug.
owner: Owner of the bug.
send_email: True to send email to bug cc list, False otherwise.
Returns:
True if successful, False otherwise.
"""
if not bug_id or bug_id < 0:
return False
body = {'content': comment}
updates = {}
# Mark issue as duplicate when relevant bug ID is found in the datastore.
# Avoid marking an issue as duplicate of itself.
if merge_issue and int(merge_issue) != bug_id:
status = 'Duplicate'
updates['mergedInto'] = merge_issue
logging.info('Bug %s marked as duplicate of %s', bug_id, merge_issue)
if status:
updates['status'] = status
if cc_list:
updates['cc'] = cc_list
if labels:
updates['labels'] = labels
if owner:
updates['owner'] = owner
body['updates'] = updates
return self._MakeCommentRequest(bug_id, body, send_email=send_email)
def List(self, **kwargs):
"""Makes a request to the issue tracker to list bugs."""
request = self._service.issues().list(projectId='chromium', **kwargs)
return self._ExecuteRequest(request)
def _MakeCommentRequest(self, bug_id, body, retry=True, send_email=False):
"""Makes a request to the issue tracker to update a bug.
Args:
bug_id: Bug ID of the issue.
body: Dict of comment parameters.
retry: True to retry on failure, False otherwise.
send_email: True to send email to bug cc list, False otherwise.
Returns:
True if successful posted a comment or issue was deleted. False if
making a comment failed unexpectedly.
"""
request = self._service.issues().comments().insert(
projectId='chromium',
issueId=bug_id,
sendEmail=send_email,
body=body)
try:
if self._ExecuteRequest(request, ignore_error=False):
return True
except errors.HttpError as e:
reason = _GetErrorReason(e)
# Retry without owner if we cannot set owner to this issue.
if retry and 'The user does not exist' in reason:
_RemoveOwnerAndCC(body)
return self._MakeCommentRequest(bug_id, body, retry=False)
# This error reason is received when issue is deleted.
elif 'User is not allowed to view this issue' in reason:
logging.warning('Unable to update bug %s with body %s', bug_id, body)
return True
logging.error('Error updating bug %s with body %s', bug_id, body)
return False
def NewBug(self, title, description, labels=None, components=None,
owner=None):
"""Creates a new bug.
Args:
title: The short title text of the bug.
description: The body text for the bug.
labels: Starting labels for the bug.
components: Starting components for the bug.
owner: Starting owner account name.
Returns:
The new bug ID if successfully created, or None.
"""
body = {
'title': title,
'summary': title,
'description': description,
'labels': labels or [],
'components': components or [],
'status': 'Assigned',
}
if owner:
body['owner'] = {'name': owner}
return self._MakeCreateRequest(body)
def _MakeCreateRequest(self, body):
"""Makes a request to create a new bug.
Args:
body: The request body parameter dictionary.
Returns:
A bug ID if successful, or None otherwise.
"""
request = self._service.issues().insert(
projectId='chromium',
sendEmail=True,
body=body)
response = self._ExecuteRequest(request)
if response and 'id' in response:
return response['id']
return None
def GetLastBugCommentsAndTimestamp(self, bug_id):
"""Gets last updated comments and timestamp in the given bug.
Args:
bug_id: Bug ID of the issue to update.
Returns:
A dictionary with last comment and timestamp, or None on failure.
"""
if not bug_id or bug_id < 0:
return None
response = self._MakeGetCommentsRequest(bug_id)
if response and all(v in response.keys()
for v in ['totalResults', 'items']):
bug_comments = response.get('items')[response.get('totalResults') - 1]
if bug_comments.get('content') and bug_comments.get('published'):
return {
'comment': bug_comments.get('content'),
'timestamp': bug_comments.get('published')
}
return None
def _MakeGetCommentsRequest(self, bug_id):
"""Makes a request to the issue tracker to get comments in the bug."""
# TODO (prasadv): By default the max number of comments retrieved in
# one request is 100. Since bisect-fyi jobs may have more then 100
# comments for now we set this maxResults count as 10000.
# Remove this max count once we find a way to clear old comments
# on FYI issues.
request = self._service.issues().comments().list(
projectId='chromium',
issueId=bug_id,
maxResults=10000)
return self._ExecuteRequest(request)
def _ExecuteRequest(self, request, ignore_error=True):
"""Makes a request to the issue tracker.
Args:
request: The request object, which has a execute method.
Returns:
The response if there was one, or else None.
"""
try:
response = request.execute()
return response
except errors.HttpError as e:
logging.error(e)
if ignore_error:
return None
raise e
def _RemoveOwnerAndCC(request_body):
if 'updates' not in request_body:
return
if 'owner' in request_body['updates']:
del request_body['updates']['owner']
if 'cc' in request_body['updates']:
del request_body['updates']['cc']
def _GetErrorReason(request_error):
if request_error.resp.get('content-type', '').startswith('application/json'):
error_json = json.loads(request_error.content).get('error')
if error_json:
return error_json.get('message')
return None