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 a layer of abstraction for the issue tracker API.""" 6 7import json 8import logging 9 10from apiclient import discovery 11from apiclient import errors 12import httplib2 13 14_DISCOVERY_URI = ('https://monorail-prod.appspot.com' 15 '/_ah/api/discovery/v1/apis/{api}/{apiVersion}/rest') 16 17 18class IssueTrackerService(object): 19 """Class for updating bug issues.""" 20 21 def __init__(self, http=None, additional_credentials=None): 22 """Initializes an object for adding and updating bugs on the issue tracker. 23 24 This object can be re-used to make multiple requests without calling 25 apliclient.discovery.build multiple times. 26 27 This class makes requests to the Monorail API. 28 API explorer: https://goo.gl/xWd0dX 29 30 Args: 31 http: A Http object to pass to request.execute; this should be an 32 Http object that's already authenticated via OAuth2. 33 additional_credentials: A credentials object, e.g. an instance of 34 oauth2client.client.SignedJwtAssertionCredentials. This includes 35 the email and secret key of a service account. 36 """ 37 self._http = http or httplib2.Http() 38 if additional_credentials: 39 additional_credentials.authorize(self._http) 40 self._service = discovery.build( 41 'monorail', 'v1', discoveryServiceUrl=_DISCOVERY_URI, 42 http=self._http) 43 44 def AddBugComment(self, bug_id, comment, status=None, cc_list=None, 45 merge_issue=None, labels=None, owner=None): 46 """Adds a comment with the bisect results to the given bug. 47 48 Args: 49 bug_id: Bug ID of the issue to update. 50 comment: Bisect results information. 51 status: A string status for bug, e.g. Assigned, Duplicate, WontFix, etc. 52 cc_list: List of email addresses of users to add to the CC list. 53 merge_issue: ID of the issue to be merged into; specifying this option 54 implies that the status should be "Duplicate". 55 labels: List of labels for bug. 56 owner: Owner of the bug. 57 58 Returns: 59 True if successful, False otherwise. 60 """ 61 if not bug_id or bug_id < 0: 62 return False 63 64 body = {'content': comment} 65 updates = {} 66 # Mark issue as duplicate when relevant bug ID is found in the datastore. 67 # Avoid marking an issue as duplicate of itself. 68 if merge_issue and int(merge_issue) != bug_id: 69 status = 'Duplicate' 70 updates['mergedInto'] = merge_issue 71 logging.info('Bug %s marked as duplicate of %s', bug_id, merge_issue) 72 if status: 73 updates['status'] = status 74 if cc_list: 75 updates['cc'] = cc_list 76 if labels: 77 updates['labels'] = labels 78 if owner: 79 updates['owner'] = owner 80 body['updates'] = updates 81 82 return self._MakeCommentRequest(bug_id, body) 83 84 def List(self, **kwargs): 85 """Makes a request to the issue tracker to list bugs.""" 86 request = self._service.issues().list(projectId='chromium', **kwargs) 87 return self._ExecuteRequest(request) 88 89 def _MakeCommentRequest(self, bug_id, body, retry=True): 90 """Makes a request to the issue tracker to update a bug. 91 92 Args: 93 bug_id: Bug ID of the issue. 94 body: Dict of comment parameters. 95 retry: True to retry on failure, False otherwise. 96 97 Returns: 98 True if successful posted a comment or issue was deleted. False if 99 making a comment failed unexpectedly. 100 """ 101 request = self._service.issues().comments().insert( 102 projectId='chromium', 103 issueId=bug_id, 104 sendEmail=True, 105 body=body) 106 try: 107 if self._ExecuteRequest(request, ignore_error=False): 108 return True 109 except errors.HttpError as e: 110 reason = _GetErrorReason(e) 111 # Retry without owner if we cannot set owner to this issue. 112 if retry and 'The user does not exist' in reason: 113 _RemoveOwnerAndCC(body) 114 return self._MakeCommentRequest(bug_id, body, retry=False) 115 # This error reason is received when issue is deleted. 116 elif 'User is not allowed to view this issue' in reason: 117 logging.warning('Unable to update bug %s with body %s', bug_id, body) 118 return True 119 logging.error('Error updating bug %s with body %s', bug_id, body) 120 return False 121 122 def NewBug(self, title, description, labels=None, components=None, 123 owner=None): 124 """Creates a new bug. 125 126 Args: 127 title: The short title text of the bug. 128 description: The body text for the bug. 129 labels: Starting labels for the bug. 130 components: Starting components for the bug. 131 owner: Starting owner account name. 132 133 Returns: 134 The new bug ID if successfully created, or None. 135 """ 136 body = { 137 'title': title, 138 'summary': title, 139 'description': description, 140 'labels': labels or [], 141 'components': components or [], 142 'status': 'Assigned', 143 } 144 if owner: 145 body['owner'] = {'name': owner} 146 return self._MakeCreateRequest(body) 147 148 def _MakeCreateRequest(self, body): 149 """Makes a request to create a new bug. 150 151 Args: 152 body: The request body parameter dictionary. 153 154 Returns: 155 A bug ID if successful, or None otherwise. 156 """ 157 request = self._service.issues().insert( 158 projectId='chromium', 159 sendEmail=True, 160 body=body) 161 response = self._ExecuteRequest(request) 162 if response and 'id' in response: 163 return response['id'] 164 return None 165 166 def GetLastBugCommentsAndTimestamp(self, bug_id): 167 """Gets last updated comments and timestamp in the given bug. 168 169 Args: 170 bug_id: Bug ID of the issue to update. 171 172 Returns: 173 A dictionary with last comment and timestamp, or None on failure. 174 """ 175 if not bug_id or bug_id < 0: 176 return None 177 response = self._MakeGetCommentsRequest(bug_id) 178 if response and all(v in response.keys() 179 for v in ['totalResults', 'items']): 180 bug_comments = response.get('items')[response.get('totalResults') - 1] 181 if bug_comments.get('content') and bug_comments.get('published'): 182 return { 183 'comment': bug_comments.get('content'), 184 'timestamp': bug_comments.get('published') 185 } 186 return None 187 188 def _MakeGetCommentsRequest(self, bug_id): 189 """Makes a request to the issue tracker to get comments in the bug.""" 190 # TODO (prasadv): By default the max number of comments retrieved in 191 # one request is 100. Since bisect-fyi jobs may have more then 100 192 # comments for now we set this maxResults count as 10000. 193 # Remove this max count once we find a way to clear old comments 194 # on FYI issues. 195 request = self._service.issues().comments().list( 196 projectId='chromium', 197 issueId=bug_id, 198 maxResults=10000) 199 return self._ExecuteRequest(request) 200 201 def _ExecuteRequest(self, request, ignore_error=True): 202 """Makes a request to the issue tracker. 203 204 Args: 205 request: The request object, which has a execute method. 206 207 Returns: 208 The response if there was one, or else None. 209 """ 210 try: 211 response = request.execute(http=self._http) 212 return response 213 except errors.HttpError as e: 214 logging.error(e) 215 if ignore_error: 216 return None 217 raise e 218 219 220def _RemoveOwnerAndCC(request_body): 221 if 'updates' not in request_body: 222 return 223 if 'owner' in request_body['updates']: 224 del request_body['updates']['owner'] 225 if 'cc' in request_body['updates']: 226 del request_body['updates']['cc'] 227 228 229def _GetErrorReason(request_error): 230 if request_error.resp.get('content-type', '').startswith('application/json'): 231 error_json = json.loads(request_error.content).get('error') 232 if error_json: 233 return error_json.get('message') 234 return None 235