[autotest] Adds areas labels to the bug filer.

1. Adds the apiclient library to external packages.

2. Query the issues tracker project to get a list of
predefined 'Area-' labels and convert all that match
the failing test into labels for the new bug. Querying
is done through the new project hosting v2 api and we use
bugdroid1's api key for authentication.

TEST=Checked that the appropriate labels are appended to failing tests.
     Repeated this with the python apiclient package uninstalled.
BUG=None

Change-Id: Iff809fe4370b0497898efc40b76d4cee7028145b
Reviewed-on: https://gerrit.chromium.org/gerrit/47304
Tested-by: Prashanth Balasubramanian <beeps@chromium.org>
Reviewed-by: Scott Zawalski <scottz@chromium.org>
Commit-Queue: Prashanth Balasubramanian <beeps@chromium.org>
diff --git a/global_config.ini b/global_config.ini
index b8ae0ad..d8f560e 100644
--- a/global_config.ini
+++ b/global_config.ini
@@ -180,9 +180,10 @@
 gs_domain: https://storage.cloud.google.com/
 chromeos_image_archive: chromeos-image-archive/
 arg_prefix: ?arg=
-retrieve_logs_cgi: http://cautotest/tko/retrieve_logs.cgi?job=/
+retrieve_logs_cgi: http://cautotest.corp.google.com/tko/retrieve_logs.cgi?job=/
 generic_results_bin: results/
 debug_dir: debug/
 buildbot_builders: http://chromegw/i/chromeos/builders/
 build_prefix: build/
 gs_file_prefix: gs://
+api_key: USE SHADOW API_KEY
diff --git a/server/cros/dynamic_suite/reporting.py b/server/cros/dynamic_suite/reporting.py
index 338f1e8..22d0e30 100644
--- a/server/cros/dynamic_suite/reporting.py
+++ b/server/cros/dynamic_suite/reporting.py
@@ -8,13 +8,13 @@
 import logging
 import re
 
-# We need to import common to be able to import chromite and requests.
 import common
 
 from autotest_lib.client.common_lib import global_config
+from autotest_lib.site_utils import phapi_lib
 
 # Try importing the essential bug reporting libraries. Chromite and gdata_lib
-# are unless they can import gdata too.
+# are useless unless they can import gdata too.
 try:
     __import__('chromite')
     __import__('gdata')
@@ -31,11 +31,11 @@
 
 
 class TestFailure(object):
-    """Wrap up all information needed to make an intelligent report about a
-    test failure.
-
-    Each TestFailure has a search marker associated with it that can be used to
-    find reports of the same error."""
+    """
+    Wrap up all information needed to make an intelligent report about a
+    test failure. Each TestFailure has a search marker associated with it
+    that can be used to find reports of the same error.
+    """
 
     # global configurations needed for build artifacts
     _gs_domain = global_config.global_config.get_config_value(
@@ -94,17 +94,12 @@
 
 
     def bug_title(self):
-        """Converts information about a failure into a string appropriate to
-        be the title of a bug."""
+        """Combines information about this failure into a title string."""
         return '[%s] %s failed on %s' % (self.suite, self.test, self.build)
 
 
     def bug_summary(self):
-        """
-        Converts information about this failure into a string appropriate
-        to be the summary of this bug. Includes the reason field and links
-        to the build artifacts and results.
-        """
+        """Combines information about this failure into a summary string."""
 
         links = self._get_links_for_failure()
         summary = ('This bug has been automatically filed to track the '
@@ -113,6 +108,7 @@
                    'build artifacts: %(build_artifacts)s.\n'
                    'results log: %(results_log)s.\n'
                    'buildbot stages: %(buildbot_stages)s.\n')
+
         specifics = {
             'test': self.test,
             'suite': self.suite,
@@ -126,28 +122,19 @@
 
 
     def search_marker(self):
-        """When filing a report about this failure, include the returned line in
-        the report to provide a way to search for this exact failure."""
+        """Return an Anchor that we can use to dedupe this exact failure."""
         return "%s(%s,%s,%s)" % ('TestFailure', self.suite,
-                                    self.test, self.reason)
+                                 self.test, self.reason)
 
 
     def _link_build_artifacts(self):
-        """
-        Link to the build artifacts.
-
-        @return: url to build artifacts on google storage.
-        """
+        """Returns an url to build artifacts on google storage."""
         return (self._gs_domain + self._arg_prefix +
                 self._chromeos_image_archive + self.build)
 
 
     def _link_result_logs(self):
-        """
-        Link to test failure logs.
-
-        @return: url to test logs on google storage.
-        """
+        """Returns an url to test logs on google storage."""
         if self.job_id and self.owner and self.hostname:
             path_to_object = '%s-%s/%s/%s' % (self.job_id, self.owner,
                                               self.hostname, self._debug_dir)
@@ -191,18 +178,15 @@
             metadata.get('builder-name') and
             metadata.get('build-number')):
 
-            return '%s%s/builds/%s' % (self._buildbot_builders,
-                                       metadata.get('builder-name'),
-                                       metadata.get('build-number'))
+            return ('%s%s/builds/%s' %
+                        (self._buildbot_builders,
+                         metadata.get('builder-name'),
+                         metadata.get('build-number'))).replace(' ', '%20')
         return 'NA'
 
 
     def _get_links_for_failure(self):
-        """
-        Get links related to this test failure.
-
-        @return: Returns a named tuple of links.
-        """
+        """Returns a named tuple of links related to this failure."""
         links = collections.namedtuple('links', ('results,'
                                                  'artifacts,'
                                                  'buildbot'))
@@ -212,8 +196,10 @@
 
 
 class Reporter(object):
-    """Files external reports about bug failures that happened inside of
-    autotest."""
+    """
+    Files external reports about bug failures that happened inside
+    autotest.
+    """
 
 
     _project_name = global_config.global_config.get_config_value(
@@ -223,11 +209,15 @@
     _password = global_config.global_config.get_config_value(
         BUG_CONFIG_SECTION, 'password', default='')
     _SEARCH_MARKER = 'ANCHOR  '
+
+    _api_key = global_config.global_config.get_config_value(
+        BUG_CONFIG_SECTION, 'api_key', default='')
+    _PREDEFINED_LABELS = ['Test-Support', 'autofiled']
     _OWNER = 'beeps@chromium.org'
 
 
     def _get_tracker(self, project, user, password):
-        """ Gets an initialized tracker object. """
+        """Returns an initialized tracker object."""
         if project and user and password:
             creds = gdata_lib.Creds()
             creds.SetCreds(user, password)
@@ -246,6 +236,9 @@
 
         self._tracker = self._get_tracker(self._project_name,
                                           self._username, self._password)
+        self._phapi_client = phapi_lib.ProjectHostingApiClient(
+                                 self._api_key,
+                                 self._project_name)
 
 
     def _check_tracker(self):
@@ -253,27 +246,6 @@
         return fundamental_libs and self._tracker
 
 
-    def report(self, failure):
-        """
-        Report about a failure on the bug tracker. If this failure has already
-        happened, post a comment on the existing bug about it occurring again.
-        If this is a new failure, create a new bug about it.
-
-        @param failure A TestFailure instance about the failure.
-        """
-        if not self._check_tracker():
-            logging.error("Can't file %s", failure.bug_title())
-            return
-
-        issue = self._find_issue_by_marker(failure.search_marker())
-        summary = '%s\n\n%s%s\n' % (failure.bug_summary(),
-                                    self._SEARCH_MARKER,
-                                    failure.search_marker())
-
-        owner = self._get_owner(failure)
-        self._add_issue_to_tracker(issue, summary, failure.bug_title(), owner)
-
-
     def _get_owner(self, failure):
         """
         Returns an owner for the given failure.
@@ -290,36 +262,91 @@
         return ''
 
 
-    def _add_issue_to_tracker(self, issue, summary, title, owner=''):
+    def _get_labels(self, test_name):
         """
-        Adds an issue to the tracker.
+        Creates labels for an issue.
 
-        Either file a new issue or append a comment to an existing issue.
+        Does a simple check to see if any of the areas listed in the
+        projects pre-defined labels are embedded in the name of the
+        failing test.
 
-        @param issue: The new issue.
+        @param test_name: name of the failing test.
+        @return: a list of labels.
+        """
+        def match_area(test_name, test_area):
+            """
+            Matches the prefix of a test name to an area, and then the suffix
+            of the area (if any) to the test name. Both parts of the test name
+            don't need to be in the area, this function prioritizes the first
+            half of the test name. A helical match is needed to allow situations
+            like the third example:
+
+            kernel_Video matches kernel, kernel-video but not kernel-audio.
+            network_Ping matches Systems-Network.
+            kernel_ConfigVerify matches kernel, but would also match both
+                                kernel-config and kernel-verify.
+
+            @param test_name: lower case test name.
+            @param test_area: lower case Cr-OS area from tracker.
+            """
+            return (test_name[:test_name.find('_')] in test_area
+                    and (not '-' in test_area or
+                         test_area[test_area.find('-')+1:] in test_name))
+
+        cros_areas = self._phapi_client.get_areas()
+        return (['Cr-OS-%s' % area for area in cros_areas
+                 if match_area(test_name, area.lower())]
+                 + self._PREDEFINED_LABELS)
+
+
+    def _create_bug_report(self, summary, title, name, owner):
+        """
+        Creates a new bug report.
+
         @param summary: A summary of the failure.
-        @param title: Title of the bug. If a bug already exists the summary gets
-            prefixed with the title.
-
-        @return: None
+        @param title: Title of the bug.
+        @param name: Failing Test name, used to assigning labels.
+        @param owner: The owner of the new bug.
         """
-        if issue:
-            summary = '%s\n\n%s' % (title, summary)
-            self._tracker.AppendTrackerIssueById(issue.id, summary, owner)
-            logging.info("Filed comment %s on %s", summary, issue.id)
-        else:
-            issue = gdata_lib.Issue(title=title, summary=summary,
-                labels=['Test-Support', 'autofiled'],
-                status='Untriaged', owner='')
-            bugid = self._tracker.CreateTrackerIssue(issue)
-            logging.info("Filing new bug %s, with summary %s", bugid,
-                                                               summary)
+        labels = self._get_labels(name.lower())
+        issue = gdata_lib.Issue(title=title,
+                                summary=summary,
+                                labels=labels,
+                                status='Untriaged',
+                                owner='')
+        bugid = self._tracker.CreateTrackerIssue(issue)
+        logging.info('Filing new bug %s, with summary %s', bugid, summary)
 
-            # The tracker api will not allow us to assign an owner to a new bug,
-            # To work around this we must first create a bug and then update it
-            # with an owner.
-            if owner:
-                self._add_issue_to_tracker(issue, '', '', owner)
+        # The tracker api will not allow us to assign an owner to a new bug,
+        # To work around this we must first create a bug and then update it
+        # with an owner. crbug.com/221757.
+        if owner:
+            self._modify_bug_report(issue.id, owner=owner)
+
+
+    def _modify_bug_report(self, issue_id, comment='', owner=''):
+        """
+        Modifies an existing bug report with a new comment or owner.
+
+        We'll catch a RequestError in at least the following cases:
+        1. If the bug report isn't really updated.
+        Eg: Update a bug with the same owner it's assigned to, without any
+            comment.
+        2. If the new owner of the bug is invalid:
+        Eg: owner='beeps@'.
+
+        Note owner='---' will un-assign without an exception on the chromium
+        tracker; owner='' will not un-assign an issue. New issues created with
+        owner='' will automatically get an owner='---'.
+
+        @param issue_id: Id of the issue to update with.
+        @param comment: Comment to update the issue with.
+        @param owner: Owner the issue with issue_id needs to get assigned to.
+        """
+        try:
+            self._tracker.AppendTrackerIssueById(issue_id, comment, owner)
+        except client.RequestError as e:
+            logging.debug(e)
 
 
     def _find_issue_by_marker(self, marker):
@@ -406,3 +433,29 @@
             if any(unescaped_clean_marker in re.sub('[0-9]+', '', comment.text)
                    for comment in issue.comments if comment.text):
                 return issue
+
+
+    def report(self, failure):
+        """
+        Report a failure to the bug tracker. If this failure has already
+        happened, post a comment on the existing bug about it occurring again.
+        If this is a new failure, create a new bug about it.
+
+        @param failure A TestFailure instance about the failure.
+        """
+        if not self._check_tracker():
+            logging.error("Can't file %s", failure.bug_title())
+            return
+
+        issue = self._find_issue_by_marker(failure.search_marker())
+        owner = self._get_owner(failure)
+        summary = '%s\n\n%s%s\n' % (failure.bug_summary(),
+                                    self._SEARCH_MARKER,
+                                    failure.search_marker())
+
+        if issue:
+            comment = '%s\n\n%s' % (failure.bug_title(), summary)
+            self._modify_bug_report(issue.id, comment, owner)
+        else:
+            self._create_bug_report(summary, failure.bug_title(),
+                                    failure.test, owner)
diff --git a/site_utils/phapi_lib.py b/site_utils/phapi_lib.py
new file mode 100644
index 0000000..98a9597
--- /dev/null
+++ b/site_utils/phapi_lib.py
@@ -0,0 +1,171 @@
+#!/usr/bin/python
+
+# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+import logging
+
+import common
+
+try:
+  from apiclient.discovery import build
+  from apiclient.http import HttpError
+except ImportError as e:
+  build = None
+  logging.info("API client for bug filing disabled. %s", e)
+
+
+class ProjectHostingApiException(Exception):
+    """
+    Raised when an api call fails, since the actual
+    HTTP error can be cryptic.
+    """
+
+
+class ProjectHostingApiClient():
+    """
+    Client class for interaction with the project hosting api.
+    """
+
+    def __init__(self, api_key, project_name):
+        if build is None:
+            logging.error('Cannot get apiclient library.')
+            return None
+
+        self._service = build('projecthosting', 'v2', developerKey=api_key)
+        self._project_name = project_name
+
+
+    def _execute_request(self, request):
+        """
+        Executes an api request checking for a reason upon failure.
+
+        @param request: request to be executed.
+        @raises: ProjectHostingApiException if we
+                 fail to execute the request.
+        @return: results from the executed request.
+        """
+        try:
+            return request.execute()
+        except HttpError, e:
+            msg = 'Unable to execute your request. '
+            if hasattr(e, 'content') and 'keyInvalid' in e.content:
+                msg += 'Your credentials have been revoked.'
+            raise ProjectHostingApiException(msg)
+
+
+    def _get_field(self, field):
+        """
+        Gets a field from the project.
+
+        This method directly queries the project hosting API using bugdroids1's,
+        api key.
+
+        @param field: A selector, which corresponds loosely to a field in the
+                      new bug description of the crosbug frontend.
+
+        @return: A json formatted python dict of the specified field's options,
+                 or None if we can't find the api library. This dictionary
+                 represents the javascript literal used by the front end tracker
+                 and can hold multiple filds.
+
+                The returned dictionary follows a template, but it's structure
+                is only loosely defined as it needs to match whatever the front
+                end describes via javascript.
+                For a new issue interface which looks like:
+
+                field 1: text box
+                              drop down: predefined value 1 = description
+                                         predefined value 2 = description
+                field 2: text box
+                              similar structure as field 1
+
+                you will get a dictionary like:
+                {
+                    'field name 1': {
+                        'project realted config': 'config value'
+                        'property': [
+                            {predefined value for property 1, description},
+                            {predefined value for property 2, description}
+                        ]
+                    },
+
+                    'field name 2': {
+                        similar structure
+                    }
+                    ...
+                }
+        """
+        project = self._service.projects()
+        request = project.get(projectId=self._project_name,
+                              fields=field)
+        return self._execute_request(request)
+
+
+    def _get_property_values(self, prop_dict):
+        """
+        Searches a dictionary as returned by _get_field for property lists,
+        then returns each value in the list. Effectively this gives us
+        all the accepted values for a property. For example, in crosbug
+        crosbug, 'properties' map to things like Status, Labels, Owner
+        etc, each of these will have a list within the issuesConfig dict. Each
+        list will contain a listing of all predefined values, this function
+        retrieves each of these lists.
+
+        @param prop_dict: dictionary which contains a list of properties.
+
+        @yield: each value in a property list. This can be a dict or any other
+                type of datastructure, the caller is responsible for handling
+                it correctly.
+        """
+        for name, property in prop_dict.iteritems():
+            if isinstance(property, list):
+                for values in property:
+                    yield values
+
+
+    def _get_cros_labels(self, prop_dict):
+        """
+        Helper function to isolate labels from the labels dictionary. This
+        dictionary is of the form:
+            {
+                "label": "Cr-OS-foo",
+                "description": "description"
+            },
+        And maps to the frontend like so:
+            Labels: Cr-???
+                    Cr-OS-foo = description
+        where Cr-OS-foo is a conveniently predefined value for Label Cr-OS-???.
+
+        @param prop_dict: a dictionary we expect the Cros label to be in.
+        @return: A lower case product area, eg: video, factory, ui.
+        """
+        label = prop_dict.get('label')
+        if label and 'Cr-OS-' in label:
+            return label.split('Cr-OS-')[1]
+
+
+    def get_areas(self):
+        """
+        Parse issue options and return a list of 'Cr-OS' labels.
+
+        @return: a list of Cr-OS labels from crosbug, eg: ['kernel', 'systems']
+        """
+        if build is None:
+            logging.error('Missing Api-client import. Cannot get area-labels.')
+            return []
+
+        try:
+            issue_options_dict = self._get_field('issuesConfig')
+        except ProjectHostingApiException as e:
+            logging.error('Unable to determine area labels: %s', str(e))
+            return []
+
+        # Since we can request multiple fields at once we need to
+        # retrieve each one from the field options dictionary, even if we're
+        # really only asking for one field.
+        issue_options = issue_options_dict.get('issuesConfig')
+        return filter(None, [self._get_cros_labels(each)
+                      for each in self._get_property_values(issue_options)
+                      if isinstance(each, dict)])
+
diff --git a/utils/external_packages.py b/utils/external_packages.py
index 9ea9a88..226bdc2 100644
--- a/utils/external_packages.py
+++ b/utils/external_packages.py
@@ -817,6 +817,22 @@
         return self.version
 
 
+class GoogleAPIClientPackage(ExternalPackage):
+    """
+    Pulls the Python Google API client library.
+    """
+    version = '1.1'
+    url_filename = 'google-api-python-client-%s.tar.gz' % version
+    local_filename = url_filename
+    urls = ('https://google-api-python-client.googlecode.com/files/%s' % (
+        url_filename),)
+    hex_sum = '2294949683e367b3d4ecaeb77502509c5af21e60'
+
+    _build_and_install = ExternalPackage._build_and_install_from_package
+    _build_and_install_current_dir = (
+                        ExternalPackage._build_and_install_current_dir_setup_py)
+
+
 class DnsPythonPackage(ExternalPackage):
     """
     dns module