• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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