You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by jo...@apache.org on 2013/07/31 21:14:43 UTC

[23/23] git commit: [#6461] Partial implementation of GC Issues importer based on gdata API

[#6461] Partial implementation of GC Issues importer based on gdata API

The gdata API was shut off, so to avoid unnecessary work, most of the
GDataAPI classes are left unimplemented and untested, but the core logic
in GoogleCodeTrackerImporter is implemented and tested.

Signed-off-by: Cory Johns <cj...@slashdotmedia.com>


Project: http://git-wip-us.apache.org/repos/asf/incubator-allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-allura/commit/c7fd7876
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/c7fd7876
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/c7fd7876

Branch: refs/heads/cj/6461
Commit: c7fd787689c12f2b95c0530eff66cfe9e509ae0f
Parents: 4343ee0
Author: Cory Johns <cj...@slashdotmedia.com>
Authored: Mon Jul 29 00:15:16 2013 +0000
Committer: Cory Johns <cj...@slashdotmedia.com>
Committed: Wed Jul 31 19:13:41 2013 +0000

----------------------------------------------------------------------
 .../forgeimporters/google/__init__.py           |   5 +-
 ForgeImporters/forgeimporters/google/code.py    |  13 +-
 ForgeImporters/forgeimporters/google/project.py |   4 +-
 ForgeImporters/forgeimporters/google/tasks.py   |   8 +-
 .../forgeimporters/google/tests/test_code.py    |  22 +-
 ForgeImporters/forgeimporters/google/tracker.py | 260 +++++++++++++++++++
 .../tests/google/test_extractor.py              |  12 +-
 .../forgeimporters/tests/google/test_tasks.py   |   8 +-
 .../forgeimporters/tests/google/test_tracker.py | 234 +++++++++++++++++
 ForgeImporters/setup.py                         |   1 +
 requirements-common.txt                         |   1 +
 11 files changed, 523 insertions(+), 45 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/google/__init__.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/__init__.py b/ForgeImporters/forgeimporters/google/__init__.py
index 17d724f..57e384b 100644
--- a/ForgeImporters/forgeimporters/google/__init__.py
+++ b/ForgeImporters/forgeimporters/google/__init__.py
@@ -56,10 +56,9 @@ class GoogleCodeProjectExtractor(object):
 
     DEFAULT_ICON = 'http://www.gstatic.com/codesite/ph/images/defaultlogo.png'
 
-    def __init__(self, project, page='project_info'):
-        gc_project_name = project.get_tool_data('google-code', 'project_name')
+    def __init__(self, allura_project, gc_project_name, page):
+        self.project = allura_project
         self.url = self.PAGE_MAP[page] % urllib.quote(gc_project_name)
-        self.project = project
         self.page = BeautifulSoup(urllib2.urlopen(self.url))
 
     def get_short_description(self):

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/google/code.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/code.py b/ForgeImporters/forgeimporters/google/code.py
index 8e047fb..ef7f800 100644
--- a/ForgeImporters/forgeimporters/google/code.py
+++ b/ForgeImporters/forgeimporters/google/code.py
@@ -86,8 +86,8 @@ class GoogleRepoImportController(BaseController):
     @require_post()
     @validate(GoogleRepoImportSchema(), error_handler=index)
     def create(self, gc_project_name, mount_point, mount_label, **kw):
-        c.project.set_tool_data('google-code', project_name=gc_project_name)
         app = GoogleRepoImporter.import_tool(c.project,
+                project_name=gc_project_name,
                 mount_point=mount_point,
                 mount_label=mount_label)
         redirect(app.url())
@@ -100,18 +100,13 @@ class GoogleRepoImporter(ToolImporter):
     tool_label = 'Google Code Source Importer'
     tool_description = 'Import your SVN, Git, or Hg repo from Google Code'
 
-    def import_tool(self, project=None, mount_point=None, mount_label=None):
+    def import_tool(self, project, project_name, mount_point=None, mount_label=None):
         """ Import a Google Code repo into a new SVN, Git, or Hg Allura tool.
 
         """
-        if not project:
-            raise Exception("You must supply a project")
-        if not project.get_tool_data('google-code', 'project_name'):
-            raise Exception("Missing Google Code project name")
-        extractor = GoogleCodeProjectExtractor(project, page='source_browse')
+        extractor = GoogleCodeProjectExtractor(project, project_name, 'source_browse')
         repo_type = extractor.get_repo_type()
-        repo_url = get_repo_url(project.get_tool_data('google-code',
-            'project_name'), repo_type)
+        repo_url = get_repo_url(project_name, repo_type)
         app = project.install_app(
                 REPO_ENTRY_POINTS[repo_type],
                 mount_point=mount_point or 'code',

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/google/project.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/project.py b/ForgeImporters/forgeimporters/google/project.py
index d579606..7416258 100644
--- a/ForgeImporters/forgeimporters/google/project.py
+++ b/ForgeImporters/forgeimporters/google/project.py
@@ -90,9 +90,9 @@ class GoogleCodeProjectImporter(base.ProjectImporter):
             redirect('.')
 
         c.project.set_tool_data('google-code', project_name=project_name)
-        tasks.import_project_info.post()
+        tasks.import_project_info.post(project_name)
         for importer_name in tools:
-            tasks.import_tool.post(importer_name)
+            tasks.import_tool.post(importer_name, project_name)
 
         flash('Welcome to the %s Project System! '
               'Your project data will be imported and should show up here shortly.' % config['site_name'])

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/google/tasks.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/tasks.py b/ForgeImporters/forgeimporters/google/tasks.py
index 65dd126..3e6e74d 100644
--- a/ForgeImporters/forgeimporters/google/tasks.py
+++ b/ForgeImporters/forgeimporters/google/tasks.py
@@ -27,8 +27,8 @@ from ..base import ToolImporter
 
 
 @task
-def import_project_info():
-    extractor = GoogleCodeProjectExtractor(c.project, 'project_info')
+def import_project_info(project_name):
+    extractor = GoogleCodeProjectExtractor(c.project, project_name, 'project_info')
     extractor.get_short_description()
     extractor.get_icon()
     extractor.get_license()
@@ -36,6 +36,6 @@ def import_project_info():
     g.post_event('project_updated')
 
 @task
-def import_tool(importer_name, mount_point=None, mount_label=None):
+def import_tool(importer_name, project_name, mount_point=None, mount_label=None):
     importer = ToolImporter.by_name(importer_name)
-    importer.import_tool(c.project, mount_point, mount_label)
+    importer.import_tool(c.project, project_name, mount_point, mount_label)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/google/tests/test_code.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/tests/test_code.py b/ForgeImporters/forgeimporters/google/tests/test_code.py
index cc91178..fe6943b 100644
--- a/ForgeImporters/forgeimporters/google/tests/test_code.py
+++ b/ForgeImporters/forgeimporters/google/tests/test_code.py
@@ -56,30 +56,20 @@ class TestGoogleRepoImporter(TestCase):
         project.get_tool_data.side_effect = lambda *args: gc_proj_name
         return project
 
-    @patch('forgeimporters.google.code.GoogleCodeProjectExtractor.get_repo_type')
+    @patch('forgeimporters.google.code.GoogleCodeProjectExtractor')
     @patch('forgeimporters.google.code.get_repo_url')
-    def test_import_tool_happy_path(self, get_repo_url, get_repo_type):
-        get_repo_type.return_value = 'git'
+    def test_import_tool_happy_path(self, get_repo_url, gcpe):
+        gcpe.return_value.get_repo_type.return_value = 'git'
         get_repo_url.return_value = 'http://remote/clone/url/'
         p = self._make_project(gc_proj_name='myproject')
-        GoogleRepoImporter().import_tool(p)
+        GoogleRepoImporter().import_tool(p, 'project_name')
+        get_repo_url.assert_called_once_with('project_name', 'git')
         p.install_app.assert_called_once_with('Git',
                 mount_point='code',
                 mount_label='Code',
                 init_from_url='http://remote/clone/url/',
                 )
 
-    def test_no_project(self):
-        with self.assertRaises(Exception) as cm:
-            GoogleRepoImporter().import_tool()
-        self.assertEqual(str(cm.exception), "You must supply a project")
-
-    def test_no_google_code_project_name(self):
-        p = self._make_project()
-        with self.assertRaises(Exception) as cm:
-            GoogleRepoImporter().import_tool(p)
-        self.assertEqual(str(cm.exception), "Missing Google Code project name")
-
 
 class TestGoogleRepoImportController(TestController, TestCase):
     def setUp(self):
@@ -110,8 +100,6 @@ class TestGoogleRepoImportController(TestController, TestCase):
                 status=302)
         project = M.Project.query.get(shortname=test_project_with_repo)
         self.assertEqual(r.location, 'http://localhost/p/{}/mymount'.format(test_project_with_repo))
-        self.assertEqual(project.get_tool_data('google-code', 'project_name'),
-                'poop')
         self.assertEqual(project._id, gri.import_tool.call_args[0][0]._id)
         self.assertEqual(u'mymount', gri.import_tool.call_args[1]['mount_point'])
         self.assertEqual(u'mylabel', gri.import_tool.call_args[1]['mount_label'])

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/google/tracker.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/tracker.py b/ForgeImporters/forgeimporters/google/tracker.py
new file mode 100644
index 0000000..602690e
--- /dev/null
+++ b/ForgeImporters/forgeimporters/google/tracker.py
@@ -0,0 +1,260 @@
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from collections import defaultdict
+from datetime import datetime
+
+from pylons import tmpl_context as c
+import gdata
+from ming.orm import session
+
+from allura.lib import helpers as h
+
+from forgetracker.tracker_main import ForgeTrackerApp
+from forgetracker import model as TM
+from ..base import ToolImporter
+
+
+class GoogleCodeTrackerImporter(ToolImporter):
+    source = 'Google Code'
+    target_app = ForgeTrackerApp
+    controller = None
+    tool_label = 'Issues'
+
+    field_types = defaultdict(lambda: 'string',
+            milestone='milestone',
+            priority='select',
+            type='select',
+        )
+
+    def import_tool(self, project, project_name, mount_point=None, mount_label=None):
+        c.app = project.install_app('tracker', mount_point, mount_label)
+        c.app.globals.open_status_names = ['New', 'Accepted', 'Started']
+        c.app.globals.closed_status_names = ['Fixed', 'Verified', 'Invalid', 'Duplicate', 'WontFix', 'Done']
+        self.custom_fields = {}
+        extractor = GDataAPIExtractor(project_name)
+        for issue in extractor.iter_issues():
+            ticket = TM.Ticket.new()
+            self.process_fields(ticket, issue)
+            self.process_labels(ticket, issue)
+            self.process_comments(ticket, extractor.iter_comments(issue))
+            session(ticket).flush(ticket)
+            session(ticket).expunge(ticket)
+        self.postprocess_custom_fields()
+        session(c.app).flush(c.app)
+        session(c.app.globals).flush(c.app.globals)
+
+    def custom_field(self, name):
+        if name not in self.custom_fields:
+            self.custom_fields[name] = {
+                    'type': self.field_types[name.lower()],
+                    'label': name,
+                    'name': u'_%s' % name.lower(),
+                    'options': set(),
+                }
+        return self.custom_fields[name]
+
+    def process_fields(self, ticket, issue):
+        ticket.summary = issue.summary
+        ticket.description = issue.description
+        ticket.status = issue.status
+        ticket.created_date = datetime.strptime(issue.created_date, '')
+        ticket.mod_date = datetime.strptime(issue.mod_date, '')
+
+    def process_labels(self, ticket, issue):
+        labels = set()
+        custom_fields = defaultdict(set)
+        for label in issue.labels:
+            if u'-' in label:
+                name, value = label.split(u'-', 1)
+                cf = self.custom_field(name)
+                cf['options'].add(value)
+                custom_fields[cf['name']].add(value)
+            else:
+                labels.add(label)
+        ticket.labels = list(labels)
+        ticket.custom_fields = {n: u', '.join(sorted(v)) for n,v in custom_fields.iteritems()}
+
+    def process_comments(self, ticket, comments):
+        for comment in comments:
+            p = ticket.thread.add_post(
+                    text = (
+                        u'Originally posted by: [{author.name}]({author.link})\n'
+                        '\n'
+                        '{body}\n'
+                        '\n'
+                        '{updates}').format(
+                            author=comment.author,
+                            body=comment.text,
+                            updates='\n'.join(
+                                '*%s*: %s' % (k,v)
+                                for k,v in comment.updates.items()
+                            ),
+                    )
+                )
+            p.add_multiple_attachments(comment.attachments)
+
+    def postprocess_custom_fields(self):
+        c.app.globals.custom_fields = []
+        for name, field in self.custom_fields.iteritems():
+            if field['name'] == '_milestone':
+                field['milestones'] = [{
+                        'name': milestone,
+                        'due_date': None,
+                        'complete': False,
+                    } for milestone in field['options']]
+                field['options'] = ''
+            elif field['type'] == 'select':
+                field['options'] = ' '.join(field['options'])
+            else:
+                field['options'] = ''
+            c.app.globals.custom_fields.append(field)
+
+
+class GDataAPIExtractor(object):
+    def __init__(self, project_name):
+        self.project_name = project_name
+
+    def iter_issues(self, limit=50):
+        """
+        Iterate over all issues for a project,
+        using paging to keep the responses reasonable.
+        """
+        start = 1
+
+        client = gdata.projecthosting.client.ProjectHostingClient()
+        while True:
+            query = gdata.projecthosting.client.Query(start_index=start, max_results=limit)
+            issues = client.get_issues(self.project_name, query=query).entry
+            if len(issues) <= 0:
+                return
+            for issue in issues:
+                yield GDataAPIIssue(issue)
+            start += limit
+
+    def iter_comments(self, issue, limit=50):
+        """
+        Iterate over all comments for a given issue,
+        using paging to keep the responses reasonable.
+        """
+        start = 1
+
+        client = gdata.projecthosting.client.ProjectHostingClient()
+        while True:
+            query = gdata.projecthosting.client.Query(start_index=start, max_results=limit)
+            issues = client.get_comments(self.project_name, query=query).entry
+            if len(issues) <= 0:
+                return
+            for comment in comments:
+                yield GDataAPIComment(comment)
+            start += limit
+
+
+class GDataAPIUser(object):
+    def __init__(self, user):
+        self.user = user
+
+    @property
+    def name(self):
+        return h.really_unicode(self.user.name.text)
+
+    @property
+    def link(self):
+        return u'http://code.google.com/u/%s' % self.name
+
+
+class GDataAPIIssue(object):
+    def __init__(self, issue):
+        self.issue = issue
+
+    @property
+    def summary(self):
+        return h.really_unicode(self.issue.title.text)
+
+    @property
+    def description(self):
+        return h.really_unicode(self.issue.content.text)
+
+    @property
+    def created_date(self):
+        return self.to_date(self.issue.published.text)
+
+    @property
+    def mod_date(self):
+        return self.to_date(self.issue.updated.text)
+
+    @property
+    def creator(self):
+        return h.really_unicode(self.issue.author[0].name.text)
+
+    @property
+    def status(self):
+        if getattr(self.issue, 'status', None) is not None:
+            return h.really_unicode(self.issue.status.text)
+        return u''
+
+    @property
+    def owner(self):
+        if getattr(self.issue, 'owner', None) is not None:
+            return h.really_unicode(self.issue.owner.username.text)
+        return u''
+
+    @property
+    def labels(self):
+        return [h.really_unicode(l.text) for l in self.issue.labels]
+
+
+class GDataAPIComment(object):
+    def __init__(self, comment):
+        self.comment = comment
+
+    @property
+    def author(self):
+        return GDataAPIUser(self.comment.author[0])
+
+    @property
+    def created_date(self):
+        return h.really_unicode(self.comment.published.text)
+
+    @property
+    def body(self):
+        return h.really_unicode(self.comment.content.text)
+
+    @property
+    def updates(self):
+        return {}
+
+    @property
+    def attachments(self):
+        return []
+
+
+class GDataAPIAttachment(object):
+    def __init__(self, attachment):
+        self.attachment = attachment
+
+    @property
+    def filename(self):
+        pass
+
+    @property
+    def type(self):
+        pass
+
+    @property
+    def file(self):
+        pass

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/tests/google/test_extractor.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/tests/google/test_extractor.py b/ForgeImporters/forgeimporters/tests/google/test_extractor.py
index e346f1e..1a3a87c 100644
--- a/ForgeImporters/forgeimporters/tests/google/test_extractor.py
+++ b/ForgeImporters/forgeimporters/tests/google/test_extractor.py
@@ -36,16 +36,15 @@ class TestGoogleCodeProjectExtractor(TestCase):
         self._p_soup.stop()
 
     def test_init(self):
-        extractor = google.GoogleCodeProjectExtractor(self.project, 'project_info')
+        extractor = google.GoogleCodeProjectExtractor(self.project, 'my-project', 'project_info')
 
-        self.project.get_tool_data.assert_called_once_with('google-code', 'project_name')
         self.urlopen.assert_called_once_with('http://code.google.com/p/my-project/')
         self.assertEqual(extractor.project, self.project)
         self.soup.assert_called_once_with(self.urlopen.return_value)
         self.assertEqual(extractor.page, self.soup.return_value)
 
     def test_get_short_description(self):
-        extractor = google.GoogleCodeProjectExtractor(self.project, 'project_info')
+        extractor = google.GoogleCodeProjectExtractor(self.project, 'my-project', 'project_info')
         extractor.page.find.return_value.string = 'My Super Project'
 
         extractor.get_short_description()
@@ -57,7 +56,7 @@ class TestGoogleCodeProjectExtractor(TestCase):
     @mock.patch.object(google, 'M')
     def test_get_icon(self, M, StringIO):
         self.urlopen.return_value.info.return_value = {'content-type': 'image/png'}
-        extractor = google.GoogleCodeProjectExtractor(self.project, 'project_info')
+        extractor = google.GoogleCodeProjectExtractor(self.project, 'my-project', 'project_info')
         extractor.page.find.return_value.attrMap = {'src': 'http://example.com/foo/bar/my-logo.png'}
         self.urlopen.reset_mock()
 
@@ -75,7 +74,7 @@ class TestGoogleCodeProjectExtractor(TestCase):
     @mock.patch.object(google, 'M')
     def test_get_license(self, M):
         self.project.trove_license = []
-        extractor = google.GoogleCodeProjectExtractor(self.project, 'project_info')
+        extractor = google.GoogleCodeProjectExtractor(self.project, 'my-project', 'project_info')
         extractor.page.find.return_value.findNext.return_value.find.return_value.string = '  New BSD License  '
         trove = M.TroveCategory.query.get.return_value
 
@@ -94,7 +93,8 @@ class TestGoogleCodeProjectExtractor(TestCase):
 
     def _make_extractor(self, html):
         from BeautifulSoup import BeautifulSoup
-        extractor = google.GoogleCodeProjectExtractor(self.project)
+        with mock.patch.object(google, 'urllib2') as urllib2:
+            extractor = google.GoogleCodeProjectExtractor(self.project, 'my-project', 'project_info')
         extractor.page = BeautifulSoup(html)
         extractor.url="http://test/source/browse"
         return extractor

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/tests/google/test_tasks.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/tests/google/test_tasks.py b/ForgeImporters/forgeimporters/tests/google/test_tasks.py
index bb9319d..23da83f 100644
--- a/ForgeImporters/forgeimporters/tests/google/test_tasks.py
+++ b/ForgeImporters/forgeimporters/tests/google/test_tasks.py
@@ -25,8 +25,8 @@ from ...google import tasks
 @mock.patch.object(tasks, 'c')
 def test_import_project_info(c, session, gpe):
     c.project = mock.Mock(name='project')
-    tasks.import_project_info()
-    gpe.assert_called_once_with(c.project, 'project_info')
+    tasks.import_project_info('my-project')
+    gpe.assert_called_once_with(c.project, 'my-project', 'project_info')
     gpe.return_value.get_short_description.assert_called_once_with()
     gpe.return_value.get_icon.assert_called_once_with()
     gpe.return_value.get_license.assert_called_once_with()
@@ -37,6 +37,6 @@ def test_import_project_info(c, session, gpe):
 @mock.patch.object(tasks, 'c')
 def test_import_tool(c, by_name):
     c.project = mock.Mock(name='project')
-    tasks.import_tool('importer_name', 'mount_point', 'mount_label')
+    tasks.import_tool('importer_name', 'project_name', 'mount_point', 'mount_label')
     by_name.assert_called_once_with('importer_name')
-    by_name.return_value.import_tool.assert_called_once_with(c.project, 'mount_point', 'mount_label')
+    by_name.return_value.import_tool.assert_called_once_with(c.project, 'project_name', 'mount_point', 'mount_label')

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/forgeimporters/tests/google/test_tracker.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/tests/google/test_tracker.py b/ForgeImporters/forgeimporters/tests/google/test_tracker.py
new file mode 100644
index 0000000..d54ac90
--- /dev/null
+++ b/ForgeImporters/forgeimporters/tests/google/test_tracker.py
@@ -0,0 +1,234 @@
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from operator import itemgetter
+from unittest import TestCase
+import mock
+
+from ...google import tracker
+
+
+class TestTrackerImporter(TestCase):
+    @mock.patch.object(tracker, 'c')
+    @mock.patch.object(tracker, 'session')
+    @mock.patch.object(tracker, 'TM')
+    @mock.patch.object(tracker, 'GDataAPIExtractor')
+    def test_import_tool(self, gdata, TM, session, c):
+        importer = tracker.GoogleCodeTrackerImporter()
+        importer.process_fields = mock.Mock()
+        importer.process_labels = mock.Mock()
+        importer.process_comments = mock.Mock()
+        importer.postprocess_custom_fields = mock.Mock()
+        project = mock.Mock()
+        app = project.install_app.return_value
+        extractor = gdata.return_value
+        issues = extractor.iter_issues.return_value = [mock.Mock(), mock.Mock()]
+        tickets = TM.Ticket.new.side_effect = [mock.Mock(), mock.Mock()]
+        comments = extractor.iter_comments.side_effect = [mock.Mock(), mock.Mock()]
+
+        importer.import_tool(project, 'project_name', 'mount_point', 'mount_label')
+
+        project.install_app.assert_called_once_with('tracker', 'mount_point', 'mount_label')
+        gdata.assert_called_once_with('project_name')
+        self.assertEqual(importer.process_fields.call_args_list, [
+                mock.call(tickets[0], issues[0]),
+                mock.call(tickets[1], issues[1]),
+            ])
+        self.assertEqual(importer.process_labels.call_args_list, [
+                mock.call(tickets[0], issues[0]),
+                mock.call(tickets[1], issues[1]),
+            ])
+        self.assertEqual(importer.process_comments.call_args_list, [
+                mock.call(tickets[0], comments[0]),
+                mock.call(tickets[1], comments[1]),
+            ])
+        self.assertEqual(extractor.iter_comments.call_args_list, [
+                mock.call(issues[0]),
+                mock.call(issues[1]),
+            ])
+        self.assertEqual(session.call_args_list, [
+                mock.call(tickets[0]),
+                mock.call(tickets[0]),
+                mock.call(tickets[1]),
+                mock.call(tickets[1]),
+                mock.call(app),
+                mock.call(app.globals),
+            ])
+        self.assertEqual(session.return_value.flush.call_args_list, [
+                mock.call(tickets[0]),
+                mock.call(tickets[1]),
+                mock.call(app),
+                mock.call(app.globals),
+            ])
+        self.assertEqual(session.return_value.expunge.call_args_list, [
+                mock.call(tickets[0]),
+                mock.call(tickets[1]),
+            ])
+
+    def test_custom_fields(self):
+        importer = tracker.GoogleCodeTrackerImporter()
+        importer.custom_fields = {}
+        importer.custom_field('Foo')
+        importer.custom_field('Milestone')
+        importer.custom_field('Priority')
+        importer.custom_field('Type')
+        self.assertEqual(importer.custom_fields, {
+                'Foo': {
+                        'type': 'string',
+                        'label': 'Foo',
+                        'name': '_foo',
+                        'options': set(),
+                    },
+                'Milestone': {
+                        'type': 'milestone',
+                        'label': 'Milestone',
+                        'name': '_milestone',
+                        'options': set(),
+                    },
+                'Priority': {
+                        'type': 'select',
+                        'label': 'Priority',
+                        'name': '_priority',
+                        'options': set(),
+                    },
+                'Type': {
+                        'type': 'select',
+                        'label': 'Type',
+                        'name': '_type',
+                        'options': set(),
+                    },
+            })
+        importer.custom_fields = {'Foo': {}}
+        importer.custom_field('Foo')
+        self.assertEqual(importer.custom_fields, {'Foo': {}})
+
+    def test_process_fields(self):
+        ticket = mock.Mock()
+        issue = mock.Mock(
+                summary='summary',
+                description='description',
+                status='status',
+                created_date='created_date',
+                mod_date='mod_date',
+            )
+        importer = tracker.GoogleCodeTrackerImporter()
+        with mock.patch.object(tracker, 'datetime') as dt:
+            dt.strptime.side_effect = lambda s,f: s
+            importer.process_fields(ticket, issue)
+            self.assertEqual(ticket.summary, 'summary')
+            self.assertEqual(ticket.description, 'description')
+            self.assertEqual(ticket.status, 'status')
+            self.assertEqual(ticket.created_date, 'created_date')
+            self.assertEqual(ticket.mod_date, 'mod_date')
+            self.assertEqual(dt.strptime.call_args_list, [
+                    mock.call('created_date', ''),
+                    mock.call('mod_date', ''),
+                ])
+
+    def test_process_labels(self):
+        ticket = mock.Mock(custom_fields={}, labels=[])
+        issue = mock.Mock(labels=['Foo-Bar', 'Baz', 'Foo-Qux'])
+        importer = tracker.GoogleCodeTrackerImporter()
+        importer.custom_field = mock.Mock(side_effect=lambda n: {'name': '_%s' % n.lower(), 'options': set()})
+        importer.process_labels(ticket, issue)
+        self.assertEqual(ticket.labels, ['Baz'])
+        self.assertEqual(ticket.custom_fields, {'_foo': 'Bar, Qux'})
+
+    def test_process_comments(self):
+        def _author(n):
+            a = mock.Mock()
+            a.name = 'author%s' % n
+            a.link = 'author%s_link' % n
+            return a
+        ticket = mock.Mock()
+        comments = [
+                mock.Mock(
+                    author=_author(1),
+                    text='text1',
+                    attachments='attachments1',
+                ),
+                mock.Mock(
+                    author=_author(2),
+                    text='text2',
+                    attachments='attachments2',
+                ),
+            ]
+        comments[0].updates.items.return_value = [('Foo', 'Bar'), ('Baz', 'Qux')]
+        comments[1].updates.items.return_value = []
+        importer = tracker.GoogleCodeTrackerImporter()
+        importer.process_comments(ticket, comments)
+        self.assertEqual(ticket.thread.add_post.call_args_list[0], mock.call(
+                text='Originally posted by: [author1](author1_link)\n'
+                '\n'
+                'text1\n'
+                '\n'
+                '*Foo*: Bar\n'
+                '*Baz*: Qux'
+            ))
+        self.assertEqual(ticket.thread.add_post.call_args_list[1], mock.call(
+                text='Originally posted by: [author2](author2_link)\n'
+                '\n'
+                'text2\n'
+                '\n'
+            ))
+        self.assertEqual(ticket.thread.add_post.return_value.add_multiple_attachments.call_args_list, [
+                mock.call('attachments1'),
+                mock.call('attachments2'),
+            ])
+
+    @mock.patch.object(tracker, 'c')
+    def test_postprocess_custom_fields(self, c):
+        importer = tracker.GoogleCodeTrackerImporter()
+        importer.custom_fields = {
+                'Foo': {
+                    'name': '_foo',
+                    'type': 'string',
+                    'options': set(['foo', 'bar']),
+                },
+                'Milestone': {
+                    'name': '_milestone',
+                    'type': 'milestone',
+                    'options': set(['foo', 'bar']),
+                },
+                'Priority': {
+                    'name': '_priority',
+                    'type': 'select',
+                    'options': set(['foo', 'bar']),
+                },
+            }
+        importer.postprocess_custom_fields()
+        self.assertEqual(sorted(c.app.globals.custom_fields, key=itemgetter('name')), [
+                {
+                    'name': '_foo',
+                    'type': 'string',
+                    'options': '',
+                },
+                {
+                    'name': '_milestone',
+                    'type': 'milestone',
+                    'options': '',
+                    'milestones': [
+                        {'name': 'foo', 'due_date': None, 'complete': False},
+                        {'name': 'bar', 'due_date': None, 'complete': False},
+                    ],
+                },
+                {
+                    'name': '_priority',
+                    'type': 'select',
+                    'options': 'foo bar',
+                },
+            ])

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/ForgeImporters/setup.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/setup.py b/ForgeImporters/setup.py
index 8af3c1a..45a08eb 100644
--- a/ForgeImporters/setup.py
+++ b/ForgeImporters/setup.py
@@ -37,5 +37,6 @@ setup(name='ForgeImporters',
       google-code = forgeimporters.google.project:GoogleCodeProjectImporter
 
       [allura.importers]
+      google-code-tracker = forgeimporters.google.tracker:GoogleCodeTrackerImporter
       google-code-repo = forgeimporters.google.code:GoogleRepoImporter
       """,)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c7fd7876/requirements-common.txt
----------------------------------------------------------------------
diff --git a/requirements-common.txt b/requirements-common.txt
index 5e261a0..e7b7ef1 100644
--- a/requirements-common.txt
+++ b/requirements-common.txt
@@ -50,6 +50,7 @@ TurboGears2==2.1.5
 WebOb==1.0.8
 # part of the stdlib, but with a version number.  see http://guide.python-distribute.org/pip.html#listing-installed-packages
 wsgiref==0.1.2
+gdata==2.0.18
 
 # tg2 deps (not used directly)
 Babel==0.9.6