blob: 0470bf29214116b0ddae5790725209eb40b8ea58 [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
beeps84e7bb42013-05-31 12:00:06 -070010import httplib2
Fang Deng60184032013-08-16 10:02:08 -070011from autotest_lib.server.cros.dynamic_suite import constants
beeps84e7bb42013-05-31 12:00:06 -070012from chromite.lib import gdata_lib
13
beepse0db09d2013-02-24 17:13:21 -080014try:
beeps84e7bb42013-05-31 12:00:06 -070015 from apiclient.discovery import build as apiclient_build
16 from apiclient import errors as apiclient_errors
17 from oauth2client import file as oauth_client_fileio
beepse0db09d2013-02-24 17:13:21 -080018except ImportError as e:
beeps84e7bb42013-05-31 12:00:06 -070019 apiclient_build = None
Aviv Keshet003ba812013-08-15 10:46:39 -070020 logging.debug("API client for bug filing disabled. %s", e)
beepse0db09d2013-02-24 17:13:21 -080021
22
23class ProjectHostingApiException(Exception):
24 """
25 Raised when an api call fails, since the actual
26 HTTP error can be cryptic.
27 """
28
29
Prashanth Be2dcdf02014-03-02 16:34:59 -080030class BaseIssue(gdata_lib.Issue):
31 """Base issue class with the minimum data to describe a tracker bug.
32 """
33 def __init__(self, t_issue):
34 kwargs = {}
35 kwargs.update((keys, t_issue.get(keys))
36 for keys in gdata_lib.Issue.SlotDefaults.keys())
37 super(BaseIssue, self).__init__(**kwargs)
38
39
40class Issue(BaseIssue):
beeps84e7bb42013-05-31 12:00:06 -070041 """
42 Class representing an Issue and it's related metadata.
43 """
44 def __init__(self, t_issue):
45 """
46 Initialize |self| from tracker issue |t_issue|
47
48 @param t_issue: The base issue we want to use to populate
49 the member variables of this object.
Prashanth Be2dcdf02014-03-02 16:34:59 -080050
51 @raises ProjectHostingApiException: If the tracker issue doesn't
52 contain all expected fields needed to create a complete issue.
beeps84e7bb42013-05-31 12:00:06 -070053 """
Prashanth Be2dcdf02014-03-02 16:34:59 -080054 super(Issue, self).__init__(t_issue)
beeps84e7bb42013-05-31 12:00:06 -070055
Prashanth Be2dcdf02014-03-02 16:34:59 -080056 try:
57 # The value keyed under 'summary' in the tracker issue
58 # is, unfortunately, not the summary but the title. The
59 # actual summary is the update at index 0.
60 self.summary = t_issue.get('updates')[0]
61 self.comments = t_issue.get('updates')[1:]
beeps84e7bb42013-05-31 12:00:06 -070062
Prashanth Be2dcdf02014-03-02 16:34:59 -080063 # open or closed statuses are classified according to labels like
64 # unconfirmed, verified, fixed etc just like through the front end.
65 self.state = t_issue.get(constants.ISSUE_STATE)
66 self.merged_into = None
67 if (t_issue.get(constants.ISSUE_STATUS)
68 == constants.ISSUE_DUPLICATE and
69 constants.ISSUE_MERGEDINTO in t_issue):
70 parent_issue_dict = t_issue.get(constants.ISSUE_MERGEDINTO)
71 self.merged_into = parent_issue_dict.get('issueId')
72 except KeyError as e:
73 raise ProjectHostingApiException('Cannot create a '
74 'complete issue %s, tracker issue: %s' % (e, t_issue))
Fang Deng60184032013-08-16 10:02:08 -070075
beeps84e7bb42013-05-31 12:00:06 -070076
beepse0db09d2013-02-24 17:13:21 -080077class ProjectHostingApiClient():
78 """
79 Client class for interaction with the project hosting api.
80 """
81
Prashanth Be2dcdf02014-03-02 16:34:59 -080082 # Maximum number of results we would like when querying the tracker.
beeps84e7bb42013-05-31 12:00:06 -070083 _max_results_for_issue = 50
Prashanth Be2dcdf02014-03-02 16:34:59 -080084 _start_index = 1
beepse0db09d2013-02-24 17:13:21 -080085
beeps84e7bb42013-05-31 12:00:06 -070086
87 def __init__(self, oauth_credentials, project_name):
88 if apiclient_build is None:
89 raise ProjectHostingApiException('Cannot get apiclient library.')
90
91 storage = oauth_client_fileio.Storage(oauth_credentials)
92 credentials = storage.get()
93 if credentials is None or credentials.invalid:
94 raise ProjectHostingApiException('Invalid credentials for Project '
95 'Hosting api. Cannot file bugs.')
96
97 http = credentials.authorize(httplib2.Http())
98 self._codesite_service = apiclient_build('projecthosting',
99 'v2', http=http)
beepse0db09d2013-02-24 17:13:21 -0800100 self._project_name = project_name
101
102
103 def _execute_request(self, request):
104 """
beeps84e7bb42013-05-31 12:00:06 -0700105 Executes an api request.
beepse0db09d2013-02-24 17:13:21 -0800106
beeps84e7bb42013-05-31 12:00:06 -0700107 @param request: An apiclient.http.HttpRequest object representing the
108 request to be executed.
109 @raises: ProjectHostingApiException if we fail to execute the request.
110 This could happen if we receive an http response that is not a
111 2xx, or if the http object itself encounters an error.
112
113 @return: A deserialized object model of the response body returned for
114 the request.
beepse0db09d2013-02-24 17:13:21 -0800115 """
116 try:
117 return request.execute()
beeps84e7bb42013-05-31 12:00:06 -0700118 except (apiclient_errors.Error, httplib2.HttpLib2Error) as e:
119 msg = 'Unable to execute your request: %s'
120 raise ProjectHostingApiException(msg % e)
beepse0db09d2013-02-24 17:13:21 -0800121
122
123 def _get_field(self, field):
124 """
125 Gets a field from the project.
126
127 This method directly queries the project hosting API using bugdroids1's,
128 api key.
129
130 @param field: A selector, which corresponds loosely to a field in the
131 new bug description of the crosbug frontend.
beeps84e7bb42013-05-31 12:00:06 -0700132 @raises: ProjectHostingApiException, if the request execution fails.
beepse0db09d2013-02-24 17:13:21 -0800133
134 @return: A json formatted python dict of the specified field's options,
135 or None if we can't find the api library. This dictionary
136 represents the javascript literal used by the front end tracker
137 and can hold multiple filds.
138
139 The returned dictionary follows a template, but it's structure
140 is only loosely defined as it needs to match whatever the front
141 end describes via javascript.
142 For a new issue interface which looks like:
143
144 field 1: text box
145 drop down: predefined value 1 = description
146 predefined value 2 = description
147 field 2: text box
148 similar structure as field 1
149
150 you will get a dictionary like:
151 {
152 'field name 1': {
153 'project realted config': 'config value'
154 'property': [
155 {predefined value for property 1, description},
156 {predefined value for property 2, description}
157 ]
158 },
159
160 'field name 2': {
161 similar structure
162 }
163 ...
164 }
165 """
beeps84e7bb42013-05-31 12:00:06 -0700166 project = self._codesite_service.projects()
beepse0db09d2013-02-24 17:13:21 -0800167 request = project.get(projectId=self._project_name,
168 fields=field)
169 return self._execute_request(request)
170
171
beeps84e7bb42013-05-31 12:00:06 -0700172 def _list_updates(self, issue_id):
173 """
174 Retrieve all updates for a given issue including comments, changes to
175 it's labels, status etc. The first element in the dictionary returned
176 by this method, is by default, the 0th update on the bug; which is the
177 entry that created it. All the text in a given update is keyed as
178 'content', and updates that contain no text, eg: a change to the status
179 of a bug, will contain the emtpy string instead.
180
181 @param issue_id: The id of the issue we want detailed information on.
182 @raises: ProjectHostingApiException, if the request execution fails.
183
184 @return: A json formatted python dict that has an entry for each update
185 performed on this issue.
186 """
187 issue_comments = self._codesite_service.issues().comments()
188 request = issue_comments.list(projectId=self._project_name,
189 issueId=issue_id,
190 maxResults=self._max_results_for_issue)
191 return self._execute_request(request)
192
193
Fang Deng60184032013-08-16 10:02:08 -0700194 def _get_issue(self, issue_id):
195 """
196 Gets an issue given it's id.
197
198 @param issue_id: A string representing the issue id.
199 @raises: ProjectHostingApiException, if failed to get the issue.
200
201 @return: A json formatted python dict that has the issue content.
202 """
203 issues = self._codesite_service.issues()
204 try:
205 request = issues.get(projectId=self._project_name,
206 issueId=issue_id)
207 except TypeError as e:
208 raise ProjectHostingApiException(
209 'Unable to get issue %s from project %s: %s' %
210 (issue_id, self._project_name, str(e)))
211 return self._execute_request(request)
212
213
Prashanth Be2dcdf02014-03-02 16:34:59 -0800214 def set_max_results(self, max_results):
215 """Set the max results to return.
216
217 @param max_results: An integer representing the maximum number of
218 matching results to return per query.
219 """
220 self._max_results_for_issue = max_results
221
222
223 def set_start_index(self, start_index):
224 """Set the start index, for paging.
225
226 @param start_index: The new start index to use.
227 """
228 self._start_index = start_index
229
230
231 def list_issues(self, **kwargs):
beeps84e7bb42013-05-31 12:00:06 -0700232 """
233 List issues containing the search marker. This method will only list
234 the summary, title and id of an issue, though it searches through the
235 comments. Eg: if we're searching for the marker '123', issues that
236 contain a comment of '123' will appear in the output, but the string
237 '123' itself may not, because the output only contains issue summaries.
238
Prashanth Be2dcdf02014-03-02 16:34:59 -0800239 @param kwargs:
240 q: The anchor string used in the search.
241 can: a string representing the search space that is passed to the
242 google api, can be 'all', 'new', 'open', 'owned', 'reported',
243 'starred', or 'to-verify', defaults to 'all'.
244 label: A string representing a single label to match.
beeps84e7bb42013-05-31 12:00:06 -0700245
246 @return: A json formatted python dict of all matching issues.
Prashanth Be2dcdf02014-03-02 16:34:59 -0800247
248 @raises: ProjectHostingApiException, if the request execution fails.
beeps84e7bb42013-05-31 12:00:06 -0700249 """
250 issues = self._codesite_service.issues()
Prashanth Be2dcdf02014-03-02 16:34:59 -0800251
252 # Asking for issues with None or '' labels will restrict the query
253 # to those issues without labels.
254 if not kwargs['label']:
255 del kwargs['label']
256
beeps84e7bb42013-05-31 12:00:06 -0700257 request = issues.list(projectId=self._project_name,
Prashanth Be2dcdf02014-03-02 16:34:59 -0800258 startIndex=self._start_index,
259 maxResults=self._max_results_for_issue,
260 **kwargs)
beeps84e7bb42013-05-31 12:00:06 -0700261 return self._execute_request(request)
262
263
beepse0db09d2013-02-24 17:13:21 -0800264 def _get_property_values(self, prop_dict):
265 """
266 Searches a dictionary as returned by _get_field for property lists,
267 then returns each value in the list. Effectively this gives us
beeps84e7bb42013-05-31 12:00:06 -0700268 all the accepted values for a property. For example, in crosbug,
269 'properties' map to things like Status, Labels, Owner etc, each of these
270 will have a list within the issuesConfig dict.
beepse0db09d2013-02-24 17:13:21 -0800271
272 @param prop_dict: dictionary which contains a list of properties.
beepse0db09d2013-02-24 17:13:21 -0800273 @yield: each value in a property list. This can be a dict or any other
274 type of datastructure, the caller is responsible for handling
275 it correctly.
276 """
277 for name, property in prop_dict.iteritems():
278 if isinstance(property, list):
279 for values in property:
280 yield values
281
282
283 def _get_cros_labels(self, prop_dict):
284 """
285 Helper function to isolate labels from the labels dictionary. This
286 dictionary is of the form:
287 {
288 "label": "Cr-OS-foo",
289 "description": "description"
290 },
291 And maps to the frontend like so:
292 Labels: Cr-???
293 Cr-OS-foo = description
294 where Cr-OS-foo is a conveniently predefined value for Label Cr-OS-???.
295
296 @param prop_dict: a dictionary we expect the Cros label to be in.
297 @return: A lower case product area, eg: video, factory, ui.
298 """
299 label = prop_dict.get('label')
300 if label and 'Cr-OS-' in label:
301 return label.split('Cr-OS-')[1]
302
303
304 def get_areas(self):
305 """
306 Parse issue options and return a list of 'Cr-OS' labels.
307
308 @return: a list of Cr-OS labels from crosbug, eg: ['kernel', 'systems']
309 """
beeps84e7bb42013-05-31 12:00:06 -0700310 if apiclient_build is None:
beepse0db09d2013-02-24 17:13:21 -0800311 logging.error('Missing Api-client import. Cannot get area-labels.')
312 return []
313
314 try:
315 issue_options_dict = self._get_field('issuesConfig')
316 except ProjectHostingApiException as e:
317 logging.error('Unable to determine area labels: %s', str(e))
318 return []
319
320 # Since we can request multiple fields at once we need to
321 # retrieve each one from the field options dictionary, even if we're
322 # really only asking for one field.
323 issue_options = issue_options_dict.get('issuesConfig')
beepsc8a875b2013-03-25 10:20:38 -0700324 if issue_options is None:
325 logging.error('The IssueConfig field does not contain issue '
326 'configuration as a member anymore; The project '
327 'hosting api might have changed.')
328 return []
329
beepse0db09d2013-02-24 17:13:21 -0800330 return filter(None, [self._get_cros_labels(each)
331 for each in self._get_property_values(issue_options)
332 if isinstance(each, dict)])
333
beeps84e7bb42013-05-31 12:00:06 -0700334
335 def create_issue(self, request_body):
336 """
337 Convert the request body into an issue on the frontend tracker.
338
339 @param request_body: A python dictionary with key-value pairs
340 that represent the fields of the issue.
341 eg: {
342 'title': 'bug title',
343 'description': 'bug description',
344 'labels': ['Type-Bug'],
345 'owner': {'name': 'owner@'},
346 'cc': [{'name': 'cc1'}, {'name': 'cc2'}]
347 }
348 Note the title and descriptions fields of a
349 new bug are not optional, all other fields are.
350 @raises: ProjectHostingApiException, if request execution fails.
351
352 @return: The response body, which will contain the metadata of the
353 issue created, or an error response code and information
354 about a failure.
355 """
356 issues = self._codesite_service.issues()
357 request = issues.insert(projectId=self._project_name, sendEmail=True,
358 body=request_body)
359 return self._execute_request(request)
360
361
362 def update_issue(self, issue_id, request_body):
363 """
364 Convert the request body into an update on an issue.
365
366 @param request_body: A python dictionary with key-value pairs
367 that represent the fields of the update.
368 eg:
369 {
370 'content': 'comment to add',
371 'updates':
372 {
373 'labels': ['Type-Bug', 'another label'],
374 'owner': 'owner@',
375 'cc': ['cc1@', cc2@'],
376 }
377 }
378 Note the owner and cc fields need to be email
379 addresses the tracker recognizes.
380 @param issue_id: The id of the issue to update.
381 @raises: ProjectHostingApiException, if request execution fails.
382
383 @return: The response body, which will contain information about the
384 update of said issue, or an error response code and information
385 about a failure.
386 """
387 issues = self._codesite_service.issues()
388 request = issues.comments().insert(projectId=self._project_name,
beepse41cf4c2013-06-20 14:43:50 -0700389 issueId=issue_id, sendEmail=False,
beeps84e7bb42013-05-31 12:00:06 -0700390 body=request_body)
391 return self._execute_request(request)
392
393
394 def _populate_issue_updates(self, t_issue):
395 """
396 Populates a tracker issue with updates.
397
398 Any issue is useless without it's updates, since the updates will
399 contain both the summary and the comments. We need at least one of
400 those to successfully dedupe. The Api doesn't allow us to grab all this
401 information in one shot because viewing the comments on an issue
402 requires more authority than just viewing it's title.
403
404 @param t_issue: The basic tracker issue, to populate with updates.
405 @raises: ProjectHostingApiException, if request execution fails.
406
407 @returns: A tracker issue, with it's updates.
408 """
409 updates = self._list_updates(t_issue['id'])
410 t_issue['updates'] = [update['content'] for update in
411 self._get_property_values(updates)
412 if update.get('content')]
413 return t_issue
414
415
416 def get_tracker_issues_by_text(self, search_text, full_text=True,
Prashanth Be2dcdf02014-03-02 16:34:59 -0800417 include_dupes=False, label=None):
beeps84e7bb42013-05-31 12:00:06 -0700418 """
419 Find all Tracker issues that contain the specified search text.
420
421 @param search_text: Anchor text to use in the search.
422 @param full_text: True if we would like an extensive search through
423 issue comments. If False the search will be restricted
424 to just summaries and titles.
Fang Deng60184032013-08-16 10:02:08 -0700425 @param include_dupes: If True, search over both open issues as well as
426 closed issues whose status is 'Duplicate'. If False,
427 only search over open issues.
Prashanth Be2dcdf02014-03-02 16:34:59 -0800428 @param label: A string representing a single label to match.
429
beeps84e7bb42013-05-31 12:00:06 -0700430 @return: A list of issues that contain the search text, or an empty list
431 when we're either unable to list issues or none match the text.
432 """
433 issue_list = []
434 try:
Fang Deng60184032013-08-16 10:02:08 -0700435 search_space = 'all' if include_dupes else 'open'
Prashanth Be2dcdf02014-03-02 16:34:59 -0800436 feed = self.list_issues(q=search_text, can=search_space,
437 label=label)
beeps84e7bb42013-05-31 12:00:06 -0700438 except ProjectHostingApiException as e:
439 logging.error('Unable to search for issues with marker %s: %s',
440 search_text, e)
441 return issue_list
442
443 for t_issue in self._get_property_values(feed):
Fang Deng60184032013-08-16 10:02:08 -0700444 state = t_issue.get(constants.ISSUE_STATE)
445 status = t_issue.get(constants.ISSUE_STATUS)
446 is_open_or_dup = (state == constants.ISSUE_OPEN or
447 (state == constants.ISSUE_CLOSED
448 and status == constants.ISSUE_DUPLICATE))
beeps84e7bb42013-05-31 12:00:06 -0700449 # All valid issues will have an issue id we can use to retrieve
450 # more information about it. If we encounter a failure mode that
451 # returns a bad Http response code but doesn't throw an exception
452 # we won't find an issue id in the returned json.
Fang Deng60184032013-08-16 10:02:08 -0700453 if t_issue.get('id') and is_open_or_dup:
Prashanth Be2dcdf02014-03-02 16:34:59 -0800454 # TODO(beeps): If this method turns into a performance
455 # bottle neck yield each issue and refactor the reporter.
456 # For now passing all issues allows us to detect when
457 # deduping fails, because multiple issues will match a
458 # given query exactly.
beeps84e7bb42013-05-31 12:00:06 -0700459 try:
Prashanth Be2dcdf02014-03-02 16:34:59 -0800460 if full_text:
461 issue = Issue(self._populate_issue_updates(t_issue))
462 else:
463 issue = BaseIssue(t_issue)
beeps84e7bb42013-05-31 12:00:06 -0700464 except ProjectHostingApiException as e:
465 logging.error('Unable to list the updates of issue %s: %s',
Prashanth Be2dcdf02014-03-02 16:34:59 -0800466 t_issue.get('id'), str(e))
467 else:
468 issue_list.append(issue)
beeps84e7bb42013-05-31 12:00:06 -0700469 return issue_list
Fang Deng60184032013-08-16 10:02:08 -0700470
471
472 def get_tracker_issue_by_id(self, issue_id):
473 """
474 Returns an issue object given the id.
475
476 @param issue_id: A string representing the issue id.
477
478 @return: An Issue object on success or None on failure.
479 """
480 try:
481 t_issue = self._get_issue(issue_id)
482 return Issue(self._populate_issue_updates(t_issue))
483 except ProjectHostingApiException as e:
484 logging.error('Creation of an Issue object for %s fails: %s',
485 issue_id, str(e))
486 return None