1#!/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
10import httplib
11import httplib2
12from autotest_lib.server.cros.dynamic_suite import constants
13from chromite.lib import gdata_lib
14
15try:
16  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
19except ImportError as e:
20  apiclient_build = None
21  logging.debug("API client for bug filing disabled. %s", e)
22
23
24class ProjectHostingApiException(Exception):
25    """
26    Raised when an api call fails, since the actual
27    HTTP error can be cryptic.
28    """
29
30
31class 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):
42    """
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.
51
52        @raises ProjectHostingApiException: If the tracker issue doesn't
53            contain all expected fields needed to create a complete issue.
54        """
55        super(Issue, self).__init__(t_issue)
56
57        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:]
63
64            # 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))
76
77
78class ProjectHostingApiClient():
79    """
80    Client class for interaction with the project hosting api.
81    """
82
83    # Maximum number of results we would like when querying the tracker.
84    _max_results_for_issue = 50
85    _start_index = 1
86
87
88    def __init__(self, oauth_credentials, project_name):
89        if apiclient_build is None:
90            raise ProjectHostingApiException('Cannot get apiclient library.')
91
92        if not oauth_credentials:
93            raise ProjectHostingApiException('No oauth_credentials is provided.')
94
95        storage = oauth_client_fileio.Storage(oauth_credentials)
96        credentials = storage.get()
97        if credentials is None or credentials.invalid:
98            raise ProjectHostingApiException('Invalid credentials for Project '
99                                             'Hosting api. Cannot file bugs.')
100
101        http = credentials.authorize(httplib2.Http())
102        try:
103            self._codesite_service = apiclient_build('projecthosting',
104                                                     'v2', http=http)
105        except (apiclient_errors.Error, httplib2.HttpLib2Error,
106                httplib.BadStatusLine) as e:
107            raise ProjectHostingApiException(str(e))
108        self._project_name = project_name
109
110
111    def _execute_request(self, request):
112        """
113        Executes an api request.
114
115        @param request: An apiclient.http.HttpRequest object representing the
116                        request to be executed.
117        @raises: ProjectHostingApiException if we fail to execute the request.
118                 This could happen if we receive an http response that is not a
119                 2xx, or if the http object itself encounters an error.
120
121        @return: A deserialized object model of the response body returned for
122                 the request.
123        """
124        try:
125            return request.execute()
126        except (apiclient_errors.Error, httplib2.HttpLib2Error,
127                httplib.BadStatusLine) as e:
128            msg = 'Unable to execute your request: %s'
129            raise ProjectHostingApiException(msg % e)
130
131
132    def _get_field(self, field):
133        """
134        Gets a field from the project.
135
136        This method directly queries the project hosting API using bugdroids1's,
137        api key.
138
139        @param field: A selector, which corresponds loosely to a field in the
140                      new bug description of the crosbug frontend.
141        @raises: ProjectHostingApiException, if the request execution fails.
142
143        @return: A json formatted python dict of the specified field's options,
144                 or None if we can't find the api library. This dictionary
145                 represents the javascript literal used by the front end tracker
146                 and can hold multiple filds.
147
148                The returned dictionary follows a template, but it's structure
149                is only loosely defined as it needs to match whatever the front
150                end describes via javascript.
151                For a new issue interface which looks like:
152
153                field 1: text box
154                              drop down: predefined value 1 = description
155                                         predefined value 2 = description
156                field 2: text box
157                              similar structure as field 1
158
159                you will get a dictionary like:
160                {
161                    'field name 1': {
162                        'project realted config': 'config value'
163                        'property': [
164                            {predefined value for property 1, description},
165                            {predefined value for property 2, description}
166                        ]
167                    },
168
169                    'field name 2': {
170                        similar structure
171                    }
172                    ...
173                }
174        """
175        project = self._codesite_service.projects()
176        request = project.get(projectId=self._project_name,
177                              fields=field)
178        return self._execute_request(request)
179
180
181    def _list_updates(self, issue_id):
182        """
183        Retrieve all updates for a given issue including comments, changes to
184        it's labels, status etc. The first element in the dictionary returned
185        by this method, is by default, the 0th update on the bug; which is the
186        entry that created it. All the text in a given update is keyed as
187        'content', and updates that contain no text, eg: a change to the status
188        of a bug, will contain the emtpy string instead.
189
190        @param issue_id: The id of the issue we want detailed information on.
191        @raises: ProjectHostingApiException, if the request execution fails.
192
193        @return: A json formatted python dict that has an entry for each update
194                 performed on this issue.
195        """
196        issue_comments = self._codesite_service.issues().comments()
197        request = issue_comments.list(projectId=self._project_name,
198                                      issueId=issue_id,
199                                      maxResults=self._max_results_for_issue)
200        return self._execute_request(request)
201
202
203    def _get_issue(self, issue_id):
204        """
205        Gets an issue given it's id.
206
207        @param issue_id: A string representing the issue id.
208        @raises: ProjectHostingApiException, if failed to get the issue.
209
210        @return: A json formatted python dict that has the issue content.
211        """
212        issues = self._codesite_service.issues()
213        try:
214            request = issues.get(projectId=self._project_name,
215                                 issueId=issue_id)
216        except TypeError as e:
217            raise ProjectHostingApiException(
218                'Unable to get issue %s from project %s: %s' %
219                (issue_id, self._project_name, str(e)))
220        return self._execute_request(request)
221
222
223    def set_max_results(self, max_results):
224        """Set the max results to return.
225
226        @param max_results: An integer representing the maximum number of
227            matching results to return per query.
228        """
229        self._max_results_for_issue = max_results
230
231
232    def set_start_index(self, start_index):
233        """Set the start index, for paging.
234
235        @param start_index: The new start index to use.
236        """
237        self._start_index = start_index
238
239
240    def list_issues(self, **kwargs):
241        """
242        List issues containing the search marker. This method will only list
243        the summary, title and id of an issue, though it searches through the
244        comments. Eg: if we're searching for the marker '123', issues that
245        contain a comment of '123' will appear in the output, but the string
246        '123' itself may not, because the output only contains issue summaries.
247
248        @param kwargs:
249            q: The anchor string used in the search.
250            can: a string representing the search space that is passed to the
251                 google api, can be 'all', 'new', 'open', 'owned', 'reported',
252                 'starred', or 'to-verify', defaults to 'all'.
253            label: A string representing a single label to match.
254
255        @return: A json formatted python dict of all matching issues.
256
257        @raises: ProjectHostingApiException, if the request execution fails.
258        """
259        issues = self._codesite_service.issues()
260
261        # Asking for issues with None or '' labels will restrict the query
262        # to those issues without labels.
263        if not kwargs['label']:
264            del kwargs['label']
265
266        request = issues.list(projectId=self._project_name,
267                              startIndex=self._start_index,
268                              maxResults=self._max_results_for_issue,
269                              **kwargs)
270        return self._execute_request(request)
271
272
273    def _get_property_values(self, prop_dict):
274        """
275        Searches a dictionary as returned by _get_field for property lists,
276        then returns each value in the list. Effectively this gives us
277        all the accepted values for a property. For example, in crosbug,
278        'properties' map to things like Status, Labels, Owner etc, each of these
279        will have a list within the issuesConfig dict.
280
281        @param prop_dict: dictionary which contains a list of properties.
282        @yield: each value in a property list. This can be a dict or any other
283                type of datastructure, the caller is responsible for handling
284                it correctly.
285        """
286        for name, property in prop_dict.iteritems():
287            if isinstance(property, list):
288                for values in property:
289                    yield values
290
291
292    def _get_cros_labels(self, prop_dict):
293        """
294        Helper function to isolate labels from the labels dictionary. This
295        dictionary is of the form:
296            {
297                "label": "Cr-OS-foo",
298                "description": "description"
299            },
300        And maps to the frontend like so:
301            Labels: Cr-???
302                    Cr-OS-foo = description
303        where Cr-OS-foo is a conveniently predefined value for Label Cr-OS-???.
304
305        @param prop_dict: a dictionary we expect the Cros label to be in.
306        @return: A lower case product area, eg: video, factory, ui.
307        """
308        label = prop_dict.get('label')
309        if label and 'Cr-OS-' in label:
310            return label.split('Cr-OS-')[1]
311
312
313    def get_areas(self):
314        """
315        Parse issue options and return a list of 'Cr-OS' labels.
316
317        @return: a list of Cr-OS labels from crosbug, eg: ['kernel', 'systems']
318        """
319        if apiclient_build is None:
320            logging.error('Missing Api-client import. Cannot get area-labels.')
321            return []
322
323        try:
324            issue_options_dict = self._get_field('issuesConfig')
325        except ProjectHostingApiException as e:
326            logging.error('Unable to determine area labels: %s', str(e))
327            return []
328
329        # Since we can request multiple fields at once we need to
330        # retrieve each one from the field options dictionary, even if we're
331        # really only asking for one field.
332        issue_options = issue_options_dict.get('issuesConfig')
333        if issue_options is None:
334            logging.error('The IssueConfig field does not contain issue '
335                          'configuration as a member anymore; The project '
336                          'hosting api might have changed.')
337            return []
338
339        return filter(None, [self._get_cros_labels(each)
340                      for each in self._get_property_values(issue_options)
341                      if isinstance(each, dict)])
342
343
344    def create_issue(self, request_body):
345        """
346        Convert the request body into an issue on the frontend tracker.
347
348        @param request_body: A python dictionary with key-value pairs
349                             that represent the fields of the issue.
350                             eg: {
351                                'title': 'bug title',
352                                'description': 'bug description',
353                                'labels': ['Type-Bug'],
354                                'owner': {'name': 'owner@'},
355                                'cc': [{'name': 'cc1'}, {'name': 'cc2'}]
356                             }
357                             Note the title and descriptions fields of a
358                             new bug are not optional, all other fields are.
359        @raises: ProjectHostingApiException, if request execution fails.
360
361        @return: The response body, which will contain the metadata of the
362                 issue created, or an error response code and information
363                 about a failure.
364        """
365        issues = self._codesite_service.issues()
366        request = issues.insert(projectId=self._project_name, sendEmail=True,
367                                body=request_body)
368        return self._execute_request(request)
369
370
371    def update_issue(self, issue_id, request_body):
372        """
373        Convert the request body into an update on an issue.
374
375        @param request_body: A python dictionary with key-value pairs
376                             that represent the fields of the update.
377                             eg:
378                             {
379                                'content': 'comment to add',
380                                'updates':
381                                {
382                                    'labels': ['Type-Bug', 'another label'],
383                                    'owner': 'owner@',
384                                    'cc': ['cc1@', cc2@'],
385                                }
386                             }
387                             Note the owner and cc fields need to be email
388                             addresses the tracker recognizes.
389        @param issue_id: The id of the issue to update.
390        @raises: ProjectHostingApiException, if request execution fails.
391
392        @return: The response body, which will contain information about the
393                 update of said issue, or an error response code and information
394                 about a failure.
395        """
396        issues = self._codesite_service.issues()
397        request = issues.comments().insert(projectId=self._project_name,
398                                           issueId=issue_id, sendEmail=False,
399                                           body=request_body)
400        return self._execute_request(request)
401
402
403    def _populate_issue_updates(self, t_issue):
404        """
405        Populates a tracker issue with updates.
406
407        Any issue is useless without it's updates, since the updates will
408        contain both the summary and the comments. We need at least one of
409        those to successfully dedupe. The Api doesn't allow us to grab all this
410        information in one shot because viewing the comments on an issue
411        requires more authority than just viewing it's title.
412
413        @param t_issue: The basic tracker issue, to populate with updates.
414        @raises: ProjectHostingApiException, if request execution fails.
415
416        @returns: A tracker issue, with it's updates.
417        """
418        updates = self._list_updates(t_issue['id'])
419        t_issue['updates'] = [update['content'] for update in
420                              self._get_property_values(updates)
421                              if update.get('content')]
422        return t_issue
423
424
425    def get_tracker_issues_by_text(self, search_text, full_text=True,
426                                   include_dupes=False, label=None):
427        """
428        Find all Tracker issues that contain the specified search text.
429
430        @param search_text: Anchor text to use in the search.
431        @param full_text: True if we would like an extensive search through
432                          issue comments. If False the search will be restricted
433                          to just summaries and titles.
434        @param include_dupes: If True, search over both open issues as well as
435                          closed issues whose status is 'Duplicate'. If False,
436                          only search over open issues.
437        @param label: A string representing a single label to match.
438
439        @return: A list of issues that contain the search text, or an empty list
440                 when we're either unable to list issues or none match the text.
441        """
442        issue_list = []
443        try:
444            search_space = 'all' if include_dupes else 'open'
445            feed = self.list_issues(q=search_text, can=search_space,
446                                    label=label)
447        except ProjectHostingApiException as e:
448            logging.error('Unable to search for issues with marker %s: %s',
449                          search_text, e)
450            return issue_list
451
452        for t_issue in self._get_property_values(feed):
453            state = t_issue.get(constants.ISSUE_STATE)
454            status = t_issue.get(constants.ISSUE_STATUS)
455            is_open_or_dup = (state == constants.ISSUE_OPEN or
456                              (state == constants.ISSUE_CLOSED
457                               and status == constants.ISSUE_DUPLICATE))
458            # All valid issues will have an issue id we can use to retrieve
459            # more information about it. If we encounter a failure mode that
460            # returns a bad Http response code but doesn't throw an exception
461            # we won't find an issue id in the returned json.
462            if t_issue.get('id') and is_open_or_dup:
463                # TODO(beeps): If this method turns into a performance
464                # bottle neck yield each issue and refactor the reporter.
465                # For now passing all issues allows us to detect when
466                # deduping fails, because multiple issues will match a
467                # given query exactly.
468                try:
469                    if full_text:
470                        issue = Issue(self._populate_issue_updates(t_issue))
471                    else:
472                        issue = BaseIssue(t_issue)
473                except ProjectHostingApiException as e:
474                    logging.error('Unable to list the updates of issue %s: %s',
475                                  t_issue.get('id'), str(e))
476                else:
477                    issue_list.append(issue)
478        return issue_list
479
480
481    def get_tracker_issue_by_id(self, issue_id):
482        """
483        Returns an issue object given the id.
484
485        @param issue_id: A string representing the issue id.
486
487        @return: An Issue object on success or None on failure.
488        """
489        try:
490            t_issue = self._get_issue(issue_id)
491            return Issue(self._populate_issue_updates(t_issue))
492        except ProjectHostingApiException as e:
493            logging.error('Creation of an Issue object for %s fails: %s',
494                          issue_id, str(e))
495            return None
496