blob: 1525701e5609544214c5dc466ae61f024449d180 [file] [log] [blame]
beepse0db09d2013-02-24 17:13:21 -08001#!/usr/bin/python
2
3# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6import logging
7
8import common
9
Prashanth Balasubramanianabd04062014-12-01 15:08:00 -080010import httplib
beeps84e7bb42013-05-31 12:00:06 -070011import httplib2
Fang Deng60184032013-08-16 10:02:08 -070012from autotest_lib.server.cros.dynamic_suite import constants
beeps84e7bb42013-05-31 12:00:06 -070013from chromite.lib import gdata_lib
14
beepse0db09d2013-02-24 17:13:21 -080015try:
beeps84e7bb42013-05-31 12:00:06 -070016 from apiclient.discovery import build as apiclient_build
17 from apiclient import errors as apiclient_errors
18 from oauth2client import file as oauth_client_fileio
beepse0db09d2013-02-24 17:13:21 -080019except ImportError as e:
beeps84e7bb42013-05-31 12:00:06 -070020 apiclient_build = None
Aviv Keshet003ba812013-08-15 10:46:39 -070021 logging.debug("API client for bug filing disabled. %s", e)
beepse0db09d2013-02-24 17:13:21 -080022
23
24class ProjectHostingApiException(Exception):
25 """
26 Raised when an api call fails, since the actual
27 HTTP error can be cryptic.
28 """
29
30
Prashanth Be2dcdf02014-03-02 16:34:59 -080031class BaseIssue(gdata_lib.Issue):
32 """Base issue class with the minimum data to describe a tracker bug.
33 """
34 def __init__(self, t_issue):
35 kwargs = {}
36 kwargs.update((keys, t_issue.get(keys))
37 for keys in gdata_lib.Issue.SlotDefaults.keys())
38 super(BaseIssue, self).__init__(**kwargs)
39
40
41class Issue(BaseIssue):
beeps84e7bb42013-05-31 12:00:06 -070042 """
43 Class representing an Issue and it's related metadata.
44 """
45 def __init__(self, t_issue):
46 """
47 Initialize |self| from tracker issue |t_issue|
48
49 @param t_issue: The base issue we want to use to populate
50 the member variables of this object.
Prashanth Be2dcdf02014-03-02 16:34:59 -080051
52 @raises ProjectHostingApiException: If the tracker issue doesn't
53 contain all expected fields needed to create a complete issue.
beeps84e7bb42013-05-31 12:00:06 -070054 """
Prashanth Be2dcdf02014-03-02 16:34:59 -080055 super(Issue, self).__init__(t_issue)
beeps84e7bb42013-05-31 12:00:06 -070056
Prashanth Be2dcdf02014-03-02 16:34:59 -080057 try:
58 # The value keyed under 'summary' in the tracker issue
59 # is, unfortunately, not the summary but the title. The
60 # actual summary is the update at index 0.
61 self.summary = t_issue.get('updates')[0]
62 self.comments = t_issue.get('updates')[1:]
beeps84e7bb42013-05-31 12:00:06 -070063
Prashanth Be2dcdf02014-03-02 16:34:59 -080064 # open or closed statuses are classified according to labels like
65 # unconfirmed, verified, fixed etc just like through the front end.
66 self.state = t_issue.get(constants.ISSUE_STATE)
67 self.merged_into = None
68 if (t_issue.get(constants.ISSUE_STATUS)
69 == constants.ISSUE_DUPLICATE and
70 constants.ISSUE_MERGEDINTO in t_issue):
71 parent_issue_dict = t_issue.get(constants.ISSUE_MERGEDINTO)
72 self.merged_into = parent_issue_dict.get('issueId')
73 except KeyError as e:
74 raise ProjectHostingApiException('Cannot create a '
75 'complete issue %s, tracker issue: %s' % (e, t_issue))
Fang Deng60184032013-08-16 10:02:08 -070076
beeps84e7bb42013-05-31 12:00:06 -070077
beepse0db09d2013-02-24 17:13:21 -080078class ProjectHostingApiClient():
79 """
80 Client class for interaction with the project hosting api.
81 """
82
Prashanth Be2dcdf02014-03-02 16:34:59 -080083 # Maximum number of results we would like when querying the tracker.
beeps84e7bb42013-05-31 12:00:06 -070084 _max_results_for_issue = 50
Prashanth Be2dcdf02014-03-02 16:34:59 -080085 _start_index = 1
beepse0db09d2013-02-24 17:13:21 -080086
beeps84e7bb42013-05-31 12:00:06 -070087
Shuqian Zhao29d74e02016-02-17 16:28:31 -080088 def __init__(self, oauth_credentials, project_name,
89 monorail_server='staging'):
beeps84e7bb42013-05-31 12:00:06 -070090 if apiclient_build is None:
91 raise ProjectHostingApiException('Cannot get apiclient library.')
92
Fang Deng6f070352015-12-09 15:00:35 -080093 if not oauth_credentials:
94 raise ProjectHostingApiException('No oauth_credentials is provided.')
95
beeps84e7bb42013-05-31 12:00:06 -070096 storage = oauth_client_fileio.Storage(oauth_credentials)
97 credentials = storage.get()
98 if credentials is None or credentials.invalid:
99 raise ProjectHostingApiException('Invalid credentials for Project '
100 'Hosting api. Cannot file bugs.')
101
102 http = credentials.authorize(httplib2.Http())
Fang Deng4f2bd732015-05-12 11:00:46 -0700103 try:
Shuqian Zhao29d74e02016-02-17 16:28:31 -0800104 url = ('https://monorail-%s.appspot.com/_ah/api/discovery/v1/'
105 'apis/{api}/{apiVersion}/rest' % monorail_server)
106 self._codesite_service = apiclient_build(
107 "monorail", "v1", http=http,
108 discoveryServiceUrl=url)
Fang Deng4f2bd732015-05-12 11:00:46 -0700109 except (apiclient_errors.Error, httplib2.HttpLib2Error,
110 httplib.BadStatusLine) as e:
111 raise ProjectHostingApiException(str(e))
beepse0db09d2013-02-24 17:13:21 -0800112 self._project_name = project_name
113
114
115 def _execute_request(self, request):
116 """
beeps84e7bb42013-05-31 12:00:06 -0700117 Executes an api request.
beepse0db09d2013-02-24 17:13:21 -0800118
beeps84e7bb42013-05-31 12:00:06 -0700119 @param request: An apiclient.http.HttpRequest object representing the
120 request to be executed.
121 @raises: ProjectHostingApiException if we fail to execute the request.
122 This could happen if we receive an http response that is not a
123 2xx, or if the http object itself encounters an error.
124
125 @return: A deserialized object model of the response body returned for
126 the request.
beepse0db09d2013-02-24 17:13:21 -0800127 """
128 try:
129 return request.execute()
Prashanth Balasubramanianabd04062014-12-01 15:08:00 -0800130 except (apiclient_errors.Error, httplib2.HttpLib2Error,
131 httplib.BadStatusLine) as e:
beeps84e7bb42013-05-31 12:00:06 -0700132 msg = 'Unable to execute your request: %s'
133 raise ProjectHostingApiException(msg % e)
beepse0db09d2013-02-24 17:13:21 -0800134
135
136 def _get_field(self, field):
137 """
138 Gets a field from the project.
139
140 This method directly queries the project hosting API using bugdroids1's,
141 api key.
142
143 @param field: A selector, which corresponds loosely to a field in the
144 new bug description of the crosbug frontend.
beeps84e7bb42013-05-31 12:00:06 -0700145 @raises: ProjectHostingApiException, if the request execution fails.
beepse0db09d2013-02-24 17:13:21 -0800146
147 @return: A json formatted python dict of the specified field's options,
148 or None if we can't find the api library. This dictionary
149 represents the javascript literal used by the front end tracker
150 and can hold multiple filds.
151
152 The returned dictionary follows a template, but it's structure
153 is only loosely defined as it needs to match whatever the front
154 end describes via javascript.
155 For a new issue interface which looks like:
156
157 field 1: text box
158 drop down: predefined value 1 = description
159 predefined value 2 = description
160 field 2: text box
161 similar structure as field 1
162
163 you will get a dictionary like:
164 {
165 'field name 1': {
166 'project realted config': 'config value'
167 'property': [
168 {predefined value for property 1, description},
169 {predefined value for property 2, description}
170 ]
171 },
172
173 'field name 2': {
174 similar structure
175 }
176 ...
177 }
178 """
beeps84e7bb42013-05-31 12:00:06 -0700179 project = self._codesite_service.projects()
beepse0db09d2013-02-24 17:13:21 -0800180 request = project.get(projectId=self._project_name,
181 fields=field)
182 return self._execute_request(request)
183
184
beeps84e7bb42013-05-31 12:00:06 -0700185 def _list_updates(self, issue_id):
186 """
187 Retrieve all updates for a given issue including comments, changes to
188 it's labels, status etc. The first element in the dictionary returned
189 by this method, is by default, the 0th update on the bug; which is the
190 entry that created it. All the text in a given update is keyed as
191 'content', and updates that contain no text, eg: a change to the status
192 of a bug, will contain the emtpy string instead.
193
194 @param issue_id: The id of the issue we want detailed information on.
195 @raises: ProjectHostingApiException, if the request execution fails.
196
197 @return: A json formatted python dict that has an entry for each update
198 performed on this issue.
199 """
200 issue_comments = self._codesite_service.issues().comments()
201 request = issue_comments.list(projectId=self._project_name,
202 issueId=issue_id,
203 maxResults=self._max_results_for_issue)
204 return self._execute_request(request)
205
206
Fang Deng60184032013-08-16 10:02:08 -0700207 def _get_issue(self, issue_id):
208 """
209 Gets an issue given it's id.
210
211 @param issue_id: A string representing the issue id.
212 @raises: ProjectHostingApiException, if failed to get the issue.
213
214 @return: A json formatted python dict that has the issue content.
215 """
216 issues = self._codesite_service.issues()
217 try:
218 request = issues.get(projectId=self._project_name,
219 issueId=issue_id)
220 except TypeError as e:
221 raise ProjectHostingApiException(
222 'Unable to get issue %s from project %s: %s' %
223 (issue_id, self._project_name, str(e)))
224 return self._execute_request(request)
225
226
Prashanth Be2dcdf02014-03-02 16:34:59 -0800227 def set_max_results(self, max_results):
228 """Set the max results to return.
229
230 @param max_results: An integer representing the maximum number of
231 matching results to return per query.
232 """
233 self._max_results_for_issue = max_results
234
235
236 def set_start_index(self, start_index):
237 """Set the start index, for paging.
238
239 @param start_index: The new start index to use.
240 """
241 self._start_index = start_index
242
243
244 def list_issues(self, **kwargs):
beeps84e7bb42013-05-31 12:00:06 -0700245 """
246 List issues containing the search marker. This method will only list
247 the summary, title and id of an issue, though it searches through the
248 comments. Eg: if we're searching for the marker '123', issues that
249 contain a comment of '123' will appear in the output, but the string
250 '123' itself may not, because the output only contains issue summaries.
251
Prashanth Be2dcdf02014-03-02 16:34:59 -0800252 @param kwargs:
253 q: The anchor string used in the search.
254 can: a string representing the search space that is passed to the
255 google api, can be 'all', 'new', 'open', 'owned', 'reported',
256 'starred', or 'to-verify', defaults to 'all'.
257 label: A string representing a single label to match.
beeps84e7bb42013-05-31 12:00:06 -0700258
259 @return: A json formatted python dict of all matching issues.
Prashanth Be2dcdf02014-03-02 16:34:59 -0800260
261 @raises: ProjectHostingApiException, if the request execution fails.
beeps84e7bb42013-05-31 12:00:06 -0700262 """
263 issues = self._codesite_service.issues()
Prashanth Be2dcdf02014-03-02 16:34:59 -0800264
265 # Asking for issues with None or '' labels will restrict the query
266 # to those issues without labels.
267 if not kwargs['label']:
268 del kwargs['label']
269
beeps84e7bb42013-05-31 12:00:06 -0700270 request = issues.list(projectId=self._project_name,
Prashanth Be2dcdf02014-03-02 16:34:59 -0800271 startIndex=self._start_index,
272 maxResults=self._max_results_for_issue,
273 **kwargs)
beeps84e7bb42013-05-31 12:00:06 -0700274 return self._execute_request(request)
275
276
beepse0db09d2013-02-24 17:13:21 -0800277 def _get_property_values(self, prop_dict):
278 """
279 Searches a dictionary as returned by _get_field for property lists,
280 then returns each value in the list. Effectively this gives us
beeps84e7bb42013-05-31 12:00:06 -0700281 all the accepted values for a property. For example, in crosbug,
282 'properties' map to things like Status, Labels, Owner etc, each of these
283 will have a list within the issuesConfig dict.
beepse0db09d2013-02-24 17:13:21 -0800284
285 @param prop_dict: dictionary which contains a list of properties.
beepse0db09d2013-02-24 17:13:21 -0800286 @yield: each value in a property list. This can be a dict or any other
287 type of datastructure, the caller is responsible for handling
288 it correctly.
289 """
290 for name, property in prop_dict.iteritems():
291 if isinstance(property, list):
292 for values in property:
293 yield values
294
295
296 def _get_cros_labels(self, prop_dict):
297 """
298 Helper function to isolate labels from the labels dictionary. This
299 dictionary is of the form:
300 {
301 "label": "Cr-OS-foo",
302 "description": "description"
303 },
304 And maps to the frontend like so:
305 Labels: Cr-???
306 Cr-OS-foo = description
307 where Cr-OS-foo is a conveniently predefined value for Label Cr-OS-???.
308
309 @param prop_dict: a dictionary we expect the Cros label to be in.
310 @return: A lower case product area, eg: video, factory, ui.
311 """
312 label = prop_dict.get('label')
313 if label and 'Cr-OS-' in label:
314 return label.split('Cr-OS-')[1]
315
316
317 def get_areas(self):
318 """
319 Parse issue options and return a list of 'Cr-OS' labels.
320
321 @return: a list of Cr-OS labels from crosbug, eg: ['kernel', 'systems']
322 """
beeps84e7bb42013-05-31 12:00:06 -0700323 if apiclient_build is None:
beepse0db09d2013-02-24 17:13:21 -0800324 logging.error('Missing Api-client import. Cannot get area-labels.')
325 return []
326
327 try:
328 issue_options_dict = self._get_field('issuesConfig')
329 except ProjectHostingApiException as e:
330 logging.error('Unable to determine area labels: %s', str(e))
331 return []
332
333 # Since we can request multiple fields at once we need to
334 # retrieve each one from the field options dictionary, even if we're
335 # really only asking for one field.
336 issue_options = issue_options_dict.get('issuesConfig')
beepsc8a875b2013-03-25 10:20:38 -0700337 if issue_options is None:
338 logging.error('The IssueConfig field does not contain issue '
339 'configuration as a member anymore; The project '
340 'hosting api might have changed.')
341 return []
342
beepse0db09d2013-02-24 17:13:21 -0800343 return filter(None, [self._get_cros_labels(each)
344 for each in self._get_property_values(issue_options)
345 if isinstance(each, dict)])
346
beeps84e7bb42013-05-31 12:00:06 -0700347
348 def create_issue(self, request_body):
349 """
350 Convert the request body into an issue on the frontend tracker.
351
352 @param request_body: A python dictionary with key-value pairs
353 that represent the fields of the issue.
354 eg: {
355 'title': 'bug title',
356 'description': 'bug description',
357 'labels': ['Type-Bug'],
358 'owner': {'name': 'owner@'},
Fang Dengd296c532016-03-21 15:58:10 -0700359 'cc': [{'name': 'cc1'}, {'name': 'cc2'}],
360 'components': ["Internals->Components"]
beeps84e7bb42013-05-31 12:00:06 -0700361 }
362 Note the title and descriptions fields of a
363 new bug are not optional, all other fields are.
364 @raises: ProjectHostingApiException, if request execution fails.
365
366 @return: The response body, which will contain the metadata of the
367 issue created, or an error response code and information
368 about a failure.
369 """
370 issues = self._codesite_service.issues()
371 request = issues.insert(projectId=self._project_name, sendEmail=True,
372 body=request_body)
373 return self._execute_request(request)
374
375
376 def update_issue(self, issue_id, request_body):
377 """
378 Convert the request body into an update on an issue.
379
380 @param request_body: A python dictionary with key-value pairs
381 that represent the fields of the update.
382 eg:
383 {
384 'content': 'comment to add',
385 'updates':
386 {
387 'labels': ['Type-Bug', 'another label'],
388 'owner': 'owner@',
389 'cc': ['cc1@', cc2@'],
390 }
391 }
392 Note the owner and cc fields need to be email
393 addresses the tracker recognizes.
394 @param issue_id: The id of the issue to update.
395 @raises: ProjectHostingApiException, if request execution fails.
396
397 @return: The response body, which will contain information about the
398 update of said issue, or an error response code and information
399 about a failure.
400 """
401 issues = self._codesite_service.issues()
402 request = issues.comments().insert(projectId=self._project_name,
beepse41cf4c2013-06-20 14:43:50 -0700403 issueId=issue_id, sendEmail=False,
beeps84e7bb42013-05-31 12:00:06 -0700404 body=request_body)
405 return self._execute_request(request)
406
407
408 def _populate_issue_updates(self, t_issue):
409 """
410 Populates a tracker issue with updates.
411
412 Any issue is useless without it's updates, since the updates will
413 contain both the summary and the comments. We need at least one of
414 those to successfully dedupe. The Api doesn't allow us to grab all this
415 information in one shot because viewing the comments on an issue
416 requires more authority than just viewing it's title.
417
418 @param t_issue: The basic tracker issue, to populate with updates.
419 @raises: ProjectHostingApiException, if request execution fails.
420
421 @returns: A tracker issue, with it's updates.
422 """
423 updates = self._list_updates(t_issue['id'])
424 t_issue['updates'] = [update['content'] for update in
425 self._get_property_values(updates)
426 if update.get('content')]
427 return t_issue
428
429
430 def get_tracker_issues_by_text(self, search_text, full_text=True,
Prashanth Be2dcdf02014-03-02 16:34:59 -0800431 include_dupes=False, label=None):
beeps84e7bb42013-05-31 12:00:06 -0700432 """
433 Find all Tracker issues that contain the specified search text.
434
435 @param search_text: Anchor text to use in the search.
436 @param full_text: True if we would like an extensive search through
437 issue comments. If False the search will be restricted
438 to just summaries and titles.
Fang Deng60184032013-08-16 10:02:08 -0700439 @param include_dupes: If True, search over both open issues as well as
440 closed issues whose status is 'Duplicate'. If False,
441 only search over open issues.
Prashanth Be2dcdf02014-03-02 16:34:59 -0800442 @param label: A string representing a single label to match.
443
beeps84e7bb42013-05-31 12:00:06 -0700444 @return: A list of issues that contain the search text, or an empty list
445 when we're either unable to list issues or none match the text.
446 """
447 issue_list = []
448 try:
Fang Deng60184032013-08-16 10:02:08 -0700449 search_space = 'all' if include_dupes else 'open'
Prashanth Be2dcdf02014-03-02 16:34:59 -0800450 feed = self.list_issues(q=search_text, can=search_space,
451 label=label)
beeps84e7bb42013-05-31 12:00:06 -0700452 except ProjectHostingApiException as e:
453 logging.error('Unable to search for issues with marker %s: %s',
454 search_text, e)
455 return issue_list
456
457 for t_issue in self._get_property_values(feed):
Fang Deng60184032013-08-16 10:02:08 -0700458 state = t_issue.get(constants.ISSUE_STATE)
459 status = t_issue.get(constants.ISSUE_STATUS)
460 is_open_or_dup = (state == constants.ISSUE_OPEN or
461 (state == constants.ISSUE_CLOSED
462 and status == constants.ISSUE_DUPLICATE))
beeps84e7bb42013-05-31 12:00:06 -0700463 # All valid issues will have an issue id we can use to retrieve
464 # more information about it. If we encounter a failure mode that
465 # returns a bad Http response code but doesn't throw an exception
466 # we won't find an issue id in the returned json.
Fang Deng60184032013-08-16 10:02:08 -0700467 if t_issue.get('id') and is_open_or_dup:
Prashanth Be2dcdf02014-03-02 16:34:59 -0800468 # TODO(beeps): If this method turns into a performance
469 # bottle neck yield each issue and refactor the reporter.
470 # For now passing all issues allows us to detect when
471 # deduping fails, because multiple issues will match a
472 # given query exactly.
beeps84e7bb42013-05-31 12:00:06 -0700473 try:
Prashanth Be2dcdf02014-03-02 16:34:59 -0800474 if full_text:
475 issue = Issue(self._populate_issue_updates(t_issue))
476 else:
477 issue = BaseIssue(t_issue)
beeps84e7bb42013-05-31 12:00:06 -0700478 except ProjectHostingApiException as e:
479 logging.error('Unable to list the updates of issue %s: %s',
Prashanth Be2dcdf02014-03-02 16:34:59 -0800480 t_issue.get('id'), str(e))
481 else:
482 issue_list.append(issue)
beeps84e7bb42013-05-31 12:00:06 -0700483 return issue_list
Fang Deng60184032013-08-16 10:02:08 -0700484
485
486 def get_tracker_issue_by_id(self, issue_id):
487 """
488 Returns an issue object given the id.
489
490 @param issue_id: A string representing the issue id.
491
492 @return: An Issue object on success or None on failure.
493 """
494 try:
495 t_issue = self._get_issue(issue_id)
496 return Issue(self._populate_issue_updates(t_issue))
497 except ProjectHostingApiException as e:
498 logging.error('Creation of an Issue object for %s fails: %s',
499 issue_id, str(e))
500 return None