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/08/07 03:12:10 UTC

[1/8] git commit: [#4931] Add download link for images in repo

Updated Branches:
  refs/heads/cj/6464 7f6429bff -> 3ae77e931 (forced update)


[#4931] Add download link for images in repo

Signed-off-by: Tim Van Steenburgh <tv...@gmail.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/0065ba1e
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/0065ba1e
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/0065ba1e

Branch: refs/heads/cj/6464
Commit: 0065ba1ec6488dfb70d7d117cc95ade240e09d89
Parents: 4343ee0
Author: Tim Van Steenburgh <tv...@gmail.com>
Authored: Tue Jul 30 21:31:03 2013 +0000
Committer: Dave Brondsema <db...@slashdotmedia.com>
Committed: Wed Jul 31 19:41:12 2013 +0000

----------------------------------------------------------------------
 Allura/allura/templates/repo/file.html | 1 +
 1 file changed, 1 insertion(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/0065ba1e/Allura/allura/templates/repo/file.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/repo/file.html b/Allura/allura/templates/repo/file.html
index 3dcb447..04f7205 100644
--- a/Allura/allura/templates/repo/file.html
+++ b/Allura/allura/templates/repo/file.html
@@ -80,6 +80,7 @@
   {% endif %}
 
   {% if blob.has_image_view %}
+    <p><a href="?format=raw">Download this file</a></p>
     <img src="?format=raw" alt=""/>
   {% elif blob.has_html_view or blob.has_pypeline_view or force_display %}
     <p><a href="?format=raw">Download this file</a></p>


[4/8] git commit: [#3154] ticket:407 added pagination and location header tests for ForgeBlog API

Posted by jo...@apache.org.
[#3154]  ticket:407 added pagination and location header tests for ForgeBlog API


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

Branch: refs/heads/cj/6464
Commit: 93d12e6989d08658dad218d7bab0ec51fb675192
Parents: e23020d
Author: Yuriy Arhipov <yu...@yandex.ru>
Authored: Mon Jul 29 16:03:35 2013 +0400
Committer: Dave Brondsema <db...@slashdotmedia.com>
Committed: Wed Jul 31 21:00:25 2013 +0000

----------------------------------------------------------------------
 ForgeBlog/forgeblog/tests/functional/test_rest.py | 6 ++++++
 1 file changed, 6 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/93d12e69/ForgeBlog/forgeblog/tests/functional/test_rest.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/tests/functional/test_rest.py b/ForgeBlog/forgeblog/tests/functional/test_rest.py
index 0ab05c2..f545c7e 100644
--- a/ForgeBlog/forgeblog/tests/functional/test_rest.py
+++ b/ForgeBlog/forgeblog/tests/functional/test_rest.py
@@ -16,6 +16,7 @@
 #       KIND, either express or implied.  See the License for the
 #       specific language governing permissions and limitations
 #       under the License.
+from datetime import date
 
 from nose.tools import assert_equal
 from allura.lib import helpers as h
@@ -43,6 +44,7 @@ class TestBlogApi(TestRestApiBase):
             'labels': 'label1, label2'
         }
         r = self.api_post('/rest/p/test/blog/', **data)
+        assert_equal(r.location, 'http://localhost:80/rest/p/test/blog/%s/%s/test/' % (date.today().strftime("%Y"), date.today().strftime("%m")))
         assert_equal(r.status_int, 201)
         url = '/rest' + BM.BlogPost.query.find().first().url()
         r = self.api_get('/rest/p/test/blog/')
@@ -56,6 +58,8 @@ class TestBlogApi(TestRestApiBase):
         assert_equal(r.json['state'], data['state'])
         assert_equal(r.json['labels'], data['labels'].split(','))
 
+
+
     def test_update_post(self):
         data = {
             'title': 'test',
@@ -175,12 +179,14 @@ class TestBlogApi(TestRestApiBase):
         self.api_post('/rest/p/test/blog/', title='test3', text='test text3', state='published')
         r = self.api_get('/rest/p/test/blog/', limit='1', page='0')
         assert_equal(r.json['posts'][0]['title'], 'test3')
+        assert_equal(len(r.json['posts']), 1)
         assert_equal(r.json['count'], 3)
         assert_equal(r.json['limit'], 1)
         assert_equal(r.json['page'], 0)
         r = self.api_get('/rest/p/test/blog/', limit='2', page='0')
         assert_equal(r.json['posts'][0]['title'], 'test3')
         assert_equal(r.json['posts'][1]['title'], 'test2')
+        assert_equal(len(r.json['posts']), 2)
         assert_equal(r.json['count'], 3)
         assert_equal(r.json['limit'], 2)
         assert_equal(r.json['page'], 0)


[7/8] git commit: [#6461] fix pyflakes issues

Posted by jo...@apache.org.
[#6461] fix pyflakes issues


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

Branch: refs/heads/cj/6464
Commit: e3663fb938d6c63d82dac151b429e90e1effb500
Parents: fb06265
Author: Dave Brondsema <db...@slashdotmedia.com>
Authored: Tue Aug 6 22:11:15 2013 +0000
Committer: Dave Brondsema <db...@slashdotmedia.com>
Committed: Tue Aug 6 22:11:15 2013 +0000

----------------------------------------------------------------------
 ForgeImporters/forgeimporters/google/tracker.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/e3663fb9/ForgeImporters/forgeimporters/google/tracker.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/tracker.py b/ForgeImporters/forgeimporters/google/tracker.py
index 4bc602c..4d37a16 100644
--- a/ForgeImporters/forgeimporters/google/tracker.py
+++ b/ForgeImporters/forgeimporters/google/tracker.py
@@ -20,6 +20,7 @@ from datetime import datetime
 
 from pylons import tmpl_context as c
 #import gdata
+gdata = None
 from ming.orm import session
 
 from allura.lib import helpers as h
@@ -156,8 +157,8 @@ class GDataAPIExtractor(object):
         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:
+            comments = client.get_comments(self.project_name, query=query).entry
+            if len(comments) <= 0:
                 return
             for comment in comments:
                 yield GDataAPIComment(comment)


[3/8] git commit: [#3154] ticket:407 ForgeBlog API refactoring

Posted by jo...@apache.org.
[#3154]  ticket:407 ForgeBlog API refactoring


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

Branch: refs/heads/cj/6464
Commit: e23020d03926ed022434aade5762221d9f3e486d
Parents: af5c8e6
Author: Yuriy Arhipov <yu...@yandex.ru>
Authored: Fri Jul 26 13:34:29 2013 +0400
Committer: Dave Brondsema <db...@slashdotmedia.com>
Committed: Wed Jul 31 21:00:24 2013 +0000

----------------------------------------------------------------------
 AlluraTest/alluratest/controller.py             |  2 +-
 ForgeBlog/forgeblog/main.py                     | 42 ++++++--------------
 ForgeBlog/forgeblog/model/blog.py               | 15 +++++++
 .../forgeblog/tests/functional/test_rest.py     | 36 +++++++++++++----
 .../forgeblog/tests/unit/test_blog_post.py      |  9 +++++
 5 files changed, 67 insertions(+), 37 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/e23020d0/AlluraTest/alluratest/controller.py
----------------------------------------------------------------------
diff --git a/AlluraTest/alluratest/controller.py b/AlluraTest/alluratest/controller.py
index 95d9b4b..7b38700 100644
--- a/AlluraTest/alluratest/controller.py
+++ b/AlluraTest/alluratest/controller.py
@@ -168,7 +168,7 @@ class TestRestApiBase(TestController):
         response = fn(
             str(path),
             params=params,
-            status=[200, 302, 400, 403, 404])
+            status=[200, 201, 302, 400, 403, 404])
         if response.status_int == 302:
             return response.follow()
         else:

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/e23020d0/ForgeBlog/forgeblog/main.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/main.py b/ForgeBlog/forgeblog/main.py
index 0f4e7e6..8bc56c0 100644
--- a/ForgeBlog/forgeblog/main.py
+++ b/ForgeBlog/forgeblog/main.py
@@ -249,15 +249,7 @@ class RootController(BaseController, FeedController):
     @without_trailing_slash
     def save(self, **kw):
         require_access(c.app, 'write')
-        post = BM.BlogPost()
-        for k,v in kw.iteritems():
-            setattr(post, k, v)
-        post.neighborhood_id=c.project.neighborhood_id
-        post.make_slug()
-        post.commit()
-        M.Thread.new(discussion_id=post.app_config.discussion_id,
-               ref_id=post.index_id(),
-               subject='%s discussion' % post.title)
+        post = BM.BlogPost.new(**kw)
         redirect(h.really_unicode(post.url()).encode('utf-8'))
 
 
@@ -451,33 +443,25 @@ class RootRestController(BaseController):
         require_access(c.app, 'read')
 
     @expose('json:')
-    def index(self, title='', text='', state='draft', labels='', **kw):
+    def index(self, title='', text='', state='draft', labels='', limit=10, page=0, **kw):
         if request.method == 'POST':
             require_access(c.app, 'write')
-            post = BM.BlogPost()
-            post.title = title
-            post.state = state
-            post.text = text
-            post.labels = labels.split(',')
-            post.neighborhood_id = c.project.neighborhood_id
-            post.make_slug()
-            M.Thread.new(discussion_id=post.app_config.discussion_id,
-                         ref_id=post.index_id(),
-                         subject='%s discussion' % post.title)
-
-            post.viewable_by = ['all']
-            post.commit()
-            return post.__json__()
+            post = BM.BlogPost.new(
+                title=title,
+                state=state,
+                text=text,
+                labels=labels.split(','),
+                **kw)
+            return exc.HTTPCreated(headers=dict(Location=h.absurl('/rest' + post.url())))
+
         else:
+            result = RootController().index(limit=limit, page=page)
+            posts = result['posts']
             post_titles = []
-            query_filter = dict(app_config_id=c.app.config._id, deleted=False)
-            if not has_access(c.app, 'write')():
-                query_filter['state'] = 'published'
-            posts = BM.BlogPost.query.find(query_filter)
             for post in posts:
                 if has_access(post, 'read')():
                     post_titles.append({'title': post.title, 'url': h.absurl('/rest' + post.url())})
-            return dict(posts=post_titles)
+            return dict(posts=post_titles, count=result['count'], limit=result['limit'], page=result['page'])
 
     @expose()
     def _lookup(self, year=None, month=None, title=None, *rest):

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/e23020d0/ForgeBlog/forgeblog/model/blog.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/model/blog.py b/ForgeBlog/forgeblog/model/blog.py
index 07983a0..2cd16e0 100644
--- a/ForgeBlog/forgeblog/model/blog.py
+++ b/ForgeBlog/forgeblog/model/blog.py
@@ -256,8 +256,23 @@ class BlogPost(M.VersionedArtifact, ActivityObject):
             M.Notification.post(
                 artifact=self, topic='metadata', text=description, subject=subject)
 
+    @classmethod
+    def new(cls, **kw):
+        post = cls()
+        for k, v in kw.iteritems():
+            setattr(post, k, v)
+        post.neighborhood_id = c.project.neighborhood_id
+        post.make_slug()
+        post.commit()
+        M.Thread.new(
+            discussion_id=post.app_config.discussion_id,
+            ref_id=post.index_id(),
+            subject='%s discussion' % post.title)
+        return post
+
     def __json__(self):
         return dict(super(BlogPost, self).__json__(),
+                    author=self.author().username,
                     title=self.title,
                     url=h.absurl('/rest' + self.url()),
                     text=self.text,

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/e23020d0/ForgeBlog/forgeblog/tests/functional/test_rest.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/tests/functional/test_rest.py b/ForgeBlog/forgeblog/tests/functional/test_rest.py
index 5addcde..0ab05c2 100644
--- a/ForgeBlog/forgeblog/tests/functional/test_rest.py
+++ b/ForgeBlog/forgeblog/tests/functional/test_rest.py
@@ -43,15 +43,16 @@ class TestBlogApi(TestRestApiBase):
             'labels': 'label1, label2'
         }
         r = self.api_post('/rest/p/test/blog/', **data)
-        assert_equal(r.status_int, 200)
+        assert_equal(r.status_int, 201)
         url = '/rest' + BM.BlogPost.query.find().first().url()
         r = self.api_get('/rest/p/test/blog/')
         assert_equal(r.json['posts'][0]['title'], 'test')
         assert_equal(r.json['posts'][0]['url'], h.absurl(url))
 
         r = self.api_get(url)
-        assert_equal(r.json['title'], 'test')
+        assert_equal(r.json['title'], data['title'])
         assert_equal(r.json['text'], data['text'])
+        assert_equal(r.json['author'], 'test-admin')
         assert_equal(r.json['state'], data['state'])
         assert_equal(r.json['labels'], data['labels'].split(','))
 
@@ -63,7 +64,7 @@ class TestBlogApi(TestRestApiBase):
             'labels': 'label1, label2'
         }
         r = self.api_post('/rest/p/test/blog/', **data)
-        assert_equal(r.status_int, 200)
+        assert_equal(r.status_int, 201)
         url = '/rest' + BM.BlogPost.query.find().first().url()
         data = {
             'text': 'test text2',
@@ -84,7 +85,7 @@ class TestBlogApi(TestRestApiBase):
             'labels': 'label1, label2'
         }
         r = self.api_post('/rest/p/test/blog/', **data)
-        assert_equal(r.status_int, 200)
+        assert_equal(r.status_int, 201)
         url = '/rest' + BM.BlogPost.query.find().first().url()
         self.api_post(url, delete='')
         r = self.api_get(url)
@@ -119,7 +120,7 @@ class TestBlogApi(TestRestApiBase):
         self.app.post('/rest/p/test/blog/',
                       params=dict(title='test', text='test text', state='published'),
                       extra_environ={'username': '*anonymous'},
-                      status=200)
+                      status=201)
 
     def test_update_post_permissons(self):
         self.api_post('/rest/p/test/blog/', title='test', text='test text', state='published')
@@ -145,7 +146,7 @@ class TestBlogApi(TestRestApiBase):
     def test_permission_draft_post(self):
         self.api_post('/rest/p/test/blog/', title='test', text='test text', state='draft')
         r = self.app.get('/rest/p/test/blog/', extra_environ={'username': '*anonymous'})
-        assert_equal(r.json, {'posts': []})
+        assert_equal(r.json['posts'], [])
         url = '/rest' + BM.BlogPost.query.find().first().url()
         self.app.post(url.encode('utf-8'),
                       params=dict(title='test2', text='test text2', state='published'),
@@ -162,8 +163,29 @@ class TestBlogApi(TestRestApiBase):
     def test_draft_post(self):
         self.api_post('/rest/p/test/blog/', title='test', text='test text', state='draft')
         r = self.app.get('/rest/p/test/blog/', extra_environ={'username': '*anonymous'})
-        assert_equal(r.json, {'posts': []})
+        assert_equal(r.json['posts'], [])
         url = '/rest' + BM.BlogPost.query.find().first().url()
         self.api_post(url, state='published')
         r = self.app.get('/rest/p/test/blog/', extra_environ={'username': '*anonymous'})
         assert_equal(r.json['posts'][0]['title'], 'test')
+
+    def test_pagination(self):
+        self.api_post('/rest/p/test/blog/', title='test1', text='test text1', state='published')
+        self.api_post('/rest/p/test/blog/', title='test2', text='test text2', state='published')
+        self.api_post('/rest/p/test/blog/', title='test3', text='test text3', state='published')
+        r = self.api_get('/rest/p/test/blog/', limit='1', page='0')
+        assert_equal(r.json['posts'][0]['title'], 'test3')
+        assert_equal(r.json['count'], 3)
+        assert_equal(r.json['limit'], 1)
+        assert_equal(r.json['page'], 0)
+        r = self.api_get('/rest/p/test/blog/', limit='2', page='0')
+        assert_equal(r.json['posts'][0]['title'], 'test3')
+        assert_equal(r.json['posts'][1]['title'], 'test2')
+        assert_equal(r.json['count'], 3)
+        assert_equal(r.json['limit'], 2)
+        assert_equal(r.json['page'], 0)
+        r = self.api_get('/rest/p/test/blog/', limit='1', page='2')
+        assert_equal(r.json['posts'][0]['title'], 'test1')
+        assert_equal(r.json['count'], 3)
+        assert_equal(r.json['limit'], 1)
+        assert_equal(r.json['page'], 2)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/e23020d0/ForgeBlog/forgeblog/tests/unit/test_blog_post.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/tests/unit/test_blog_post.py b/ForgeBlog/forgeblog/tests/unit/test_blog_post.py
index c915278..919ac14 100644
--- a/ForgeBlog/forgeblog/tests/unit/test_blog_post.py
+++ b/ForgeBlog/forgeblog/tests/unit/test_blog_post.py
@@ -26,6 +26,15 @@ from allura.model import Feed
 def wrapped(s):
     return '<div class="markdown_content"><p>%s</p></div>' % s
 
+
+class TestBlogPost(BlogTestWithModel):
+    def test_new(self):
+        post = M.BlogPost.new(title='test', text='test message', state='published')
+        assert_equal(post.title, 'test')
+        assert_equal(post.text, 'test message')
+        assert_equal(post.state, 'published')
+
+
 class TestFeed(BlogTestWithModel):
     def testd(self):
         post = M.BlogPost()


[8/8] git commit: [#6464] Google Code Tracker Importer via web scraping

Posted by jo...@apache.org.
[#6464] Google Code Tracker Importer via web scraping

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/3ae77e93
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/3ae77e93
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/3ae77e93

Branch: refs/heads/cj/6464
Commit: 3ae77e9315fd6b5c1cc0c40b223f26a17b69f90c
Parents: e3663fb
Author: Cory Johns <cj...@slashdotmedia.com>
Authored: Tue Aug 6 23:47:14 2013 +0000
Committer: Cory Johns <cj...@slashdotmedia.com>
Committed: Wed Aug 7 01:11:52 2013 +0000

----------------------------------------------------------------------
 Allura/allura/controllers/site_admin.py         |  15 +-
 .../allura/templates/site_admin_task_view.html  |   8 +
 .../forgeimporters/google/__init__.py           | 150 +++++++++++++--
 ForgeImporters/forgeimporters/google/tasks.py   |   8 +-
 ForgeImporters/forgeimporters/google/tracker.py | 189 +++----------------
 .../tests/google/test_extractor.py              |  19 +-
 .../forgeimporters/tests/google/test_tasks.py   |   8 +-
 .../forgeimporters/tests/google/test_tracker.py |  98 +++++-----
 8 files changed, 261 insertions(+), 234 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/3ae77e93/Allura/allura/controllers/site_admin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/site_admin.py b/Allura/allura/controllers/site_admin.py
index 4f8f835..50b0e38 100644
--- a/Allura/allura/controllers/site_admin.py
+++ b/Allura/allura/controllers/site_admin.py
@@ -30,6 +30,7 @@ import tg
 from pylons import tmpl_context as c, app_globals as g
 from pylons import request
 from formencode import validators, Invalid
+from webob.exc import HTTPNotFound
 
 from allura.lib import helpers as h
 from allura.lib import validators as v
@@ -321,7 +322,19 @@ class TaskManagerController(object):
             config_dict['user'] = user
         with h.push_config(c, **config_dict):
             task = task.post(*args, **kw)
-        redirect('view/%s' % task._id)
+        redirect('../view/%s' % task._id)
+
+    @expose()
+    @require_post()
+    def resubmit(self, task_id):
+        try:
+            task = M.monq_model.MonQTask.query.get(_id=bson.ObjectId(task_id))
+        except bson.errors.InvalidId as e:
+            task = None
+        if task is None:
+            raise HTTPNotFound()
+        task.state = 'ready'
+        redirect('../view/%s' % task._id)
 
     @expose('json:')
     def task_doc(self, task_name):

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/3ae77e93/Allura/allura/templates/site_admin_task_view.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/site_admin_task_view.html b/Allura/allura/templates/site_admin_task_view.html
index c107382..e363b8d 100644
--- a/Allura/allura/templates/site_admin_task_view.html
+++ b/Allura/allura/templates/site_admin_task_view.html
@@ -66,6 +66,9 @@
     #task_details td.second-column {
         border: 0;
     }
+    #resubmit-task-form {
+        float: right;
+    }
 </style>
 {% endblock %}
 
@@ -73,6 +76,11 @@
 {% if not task %}
     Task not found
 {% else %}
+    {% if task.state in ['error', 'complete'] %}
+    <form id="resubmit-task-form" action="../resubmit/{{task._id}}" method="POST">
+        <input type="submit" value="Re-Submit Task" />
+    </form>
+    {% endif %}
     <h2>Task Details</h2>
     <table id="task_details">
         <tr>

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/3ae77e93/ForgeImporters/forgeimporters/google/__init__.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/__init__.py b/ForgeImporters/forgeimporters/google/__init__.py
index 57e384b..a2a335d 100644
--- a/ForgeImporters/forgeimporters/google/__init__.py
+++ b/ForgeImporters/forgeimporters/google/__init__.py
@@ -20,6 +20,7 @@ import urllib
 import urllib2
 from urlparse import urlparse, urljoin
 from collections import defaultdict
+from contextlib import closing
 try:
     from cStringIO import StringIO
 except ImportError:
@@ -33,12 +34,32 @@ from allura import model as M
 
 log = logging.getLogger(__name__)
 
+def _as_text(node, chunks=None):
+    """
+    Similar to node.text, but preserves whitespace around tags,
+    and converts <br/>s to \n.
+    """
+    if chunks is None:
+        chunks = []
+    for n in node:
+        if isinstance(n, basestring):
+            chunks.append(n)
+        elif n.name == 'br':
+            chunks.append('\n')
+        else:
+            _as_text(n, chunks)
+    return ''.join(chunks)
+
+
 class GoogleCodeProjectExtractor(object):
+    BASE_URL = 'http://code.google.com'
     RE_REPO_TYPE = re.compile(r'(svn|hg|git)')
 
     PAGE_MAP = {
-            'project_info': 'http://code.google.com/p/%s/',
-            'source_browse': 'http://code.google.com/p/%s/source/browse/',
+            'project_info': BASE_URL + '/p/{project_name}/',
+            'source_browse': BASE_URL + '/p/{project_name}/source/browse/',
+            'issues_csv': BASE_URL + '/p/{project_name}/issues/csv?can=1&colspec=ID&start={start}',
+            'issue': BASE_URL + '/p/{project_name}/issues/detail?id={issue_id}',
         }
 
     LICENSE_MAP = defaultdict(lambda:'Other/Proprietary License', {
@@ -56,15 +77,16 @@ class GoogleCodeProjectExtractor(object):
 
     DEFAULT_ICON = 'http://www.gstatic.com/codesite/ph/images/defaultlogo.png'
 
-    def __init__(self, allura_project, gc_project_name, page):
-        self.project = allura_project
-        self.url = self.PAGE_MAP[page] % urllib.quote(gc_project_name)
+    def __init__(self, project_name, page_name, **kw):
+        self.url = self.PAGE_MAP[page_name].format(
+                project_name=urllib.quote(project_name),
+                **kw)
         self.page = BeautifulSoup(urllib2.urlopen(self.url))
 
-    def get_short_description(self):
-        self.project.short_description = self.page.find(itemprop='description').string.strip()
+    def get_short_description(self, project):
+        project.short_description = self.page.find(itemprop='description').string.strip()
 
-    def get_icon(self):
+    def get_icon(self, project):
         icon_url = urljoin(self.url, self.page.find(itemprop='image').attrMap['src'])
         if icon_url == self.DEFAULT_ICON:
             return
@@ -75,12 +97,12 @@ class GoogleCodeProjectExtractor(object):
             icon_name, fp,
             fp_ish.info()['content-type'].split(';')[0],  # strip off charset=x extra param,
             square=True, thumbnail_size=(48,48),
-            thumbnail_meta={'project_id': self.project._id, 'category': 'icon'})
+            thumbnail_meta={'project_id': project._id, 'category': 'icon'})
 
-    def get_license(self):
+    def get_license(self, project):
         license = self.page.find(text='Code license').findNext().find('a').string.strip()
         trove = M.TroveCategory.query.get(fullname=self.LICENSE_MAP[license])
-        self.project.trove_license.append(trove._id)
+        project.trove_license.append(trove._id)
 
     def get_repo_type(self):
         repo_type = self.page.find(id="crumb_root")
@@ -92,3 +114,109 @@ class GoogleCodeProjectExtractor(object):
             return re_match.group(0)
         else:
             raise Exception("Unknown repo type: {0}".format(repo_type.text))
+
+    @classmethod
+    def _get_issue_ids_page(cls, project_name, start):
+        url = cls.PAGE_MAP['issues_csv'].format(project_name=project_name, start=start)
+        with closing(urllib2.urlopen(url)) as fp:
+            lines = fp.readlines()[1:]  # skip CSV header
+            if not lines[-1].startswith('"'):
+                lines.pop()  # skip "next page here" info footer
+        issue_ids = [line.strip('",\n') for line in lines]
+        return issue_ids
+
+    @classmethod
+    def iter_issues(cls, project_name):
+        """
+        Iterate over all issues for a project,
+        using paging to keep the responses reasonable.
+        """
+        start = 0
+        limit = 100
+
+        while True:
+            issue_ids = cls._get_issue_ids_page(project_name, start)
+            if len(issue_ids) <= 0:
+                return
+            for issue_id in issue_ids:
+                yield cls(project_name, 'issue', issue_id=issue_id)
+            start += limit
+
+    def get_issue_summary(self):
+        return self.page.find(id='issueheader').findAll('td', limit=2)[1].span.string.strip()
+
+    def get_issue_description(self):
+        return _as_text(self.page.find(id='hc0').pre)
+
+    def get_issue_created_date(self):
+        return self.page.find(id='hc0').find('span', 'date').get('title')
+
+    def get_issue_mod_date(self):
+        last_update = Comment(self.page.findAll('div', 'issuecomment')[-1])
+        return last_update.created_date
+
+    def get_issue_creator(self):
+        a = self.page.find(id='hc0').find('a', 'userlink')
+        return UserLink(a)
+
+    def get_issue_status(self):
+        return self.page.find(id='issuemeta').find('th', text=re.compile('Status:')).findNext().span.string.strip()
+
+    def get_issue_owner(self):
+        return UserLink(self.page.find(id='issuemeta').find('th', text=re.compile('Owner:')).findNext().a)
+
+    def get_issue_labels(self):
+        label_nodes = self.page.find(id='issuemeta').findAll('a', 'label')
+        return [_as_text(l) for l in label_nodes]
+
+    def get_issue_attachments(self):
+        attachments = self.page.find(id='hc0').find('div', 'attachments')
+        if attachments:
+            return map(Attachment, attachments.findAll('tr'))
+        else:
+            return []
+
+    def iter_comments(self):
+        for comment in self.page.findAll('div', 'issuecomment'):
+            yield Comment(comment)
+
+class UserLink(object):
+    def __init__(self, tag):
+        self.name = tag.string.strip()
+        self.link = urljoin(GoogleCodeProjectExtractor.BASE_URL, tag.get('href'))
+
+class Comment(object):
+    def __init__(self, tag):
+        self.author = UserLink(tag.find('span', 'author').find('a', 'userlink'))
+        self.created_date = tag.find('span', 'date').get('title')
+        self.body = _as_text(tag.find('pre'))
+        self._get_updates(tag)
+        self._get_attachments(tag)
+
+    def _get_updates(self, tag):
+        _updates = tag.find('div', 'updates')
+        if _updates:
+            _strings = _updates.findAll(text=True)
+            updates = (s.strip() for s in _strings if s.strip())
+            self.updates = {field: updates.next() for field in updates}
+        else:
+            self.updates = {}
+
+    def _get_attachments(self, tag):
+        attachments = tag.find('div', 'attachments')
+        if attachments:
+            self.attachments = map(Attachment, attachments.findAll('tr'))
+        else:
+            self.attachments = []
+
+class Attachment(object):
+    def __init__(self, tag):
+        self.filename = _as_text(tag).strip().split()[0]
+        self.url = urljoin(GoogleCodeProjectExtractor.BASE_URL, tag.a.get('href'))
+        self.type = None
+
+    @property
+    def file(self):
+        fp_ish = urllib2.urlopen(self.url)
+        fp = StringIO(fp_ish.read())
+        return fp

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/3ae77e93/ForgeImporters/forgeimporters/google/tasks.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/tasks.py b/ForgeImporters/forgeimporters/google/tasks.py
index 3e6e74d..2d9e182 100644
--- a/ForgeImporters/forgeimporters/google/tasks.py
+++ b/ForgeImporters/forgeimporters/google/tasks.py
@@ -28,10 +28,10 @@ from ..base import ToolImporter
 
 @task
 def import_project_info(project_name):
-    extractor = GoogleCodeProjectExtractor(c.project, project_name, 'project_info')
-    extractor.get_short_description()
-    extractor.get_icon()
-    extractor.get_license()
+    extractor = GoogleCodeProjectExtractor(project_name, 'project_info')
+    extractor.get_short_description(c.project)
+    extractor.get_icon(c.project)
+    extractor.get_license(c.project)
     ThreadLocalORMSession.flush_all()
     g.post_event('project_updated')
 

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/3ae77e93/ForgeImporters/forgeimporters/google/tracker.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/tracker.py b/ForgeImporters/forgeimporters/google/tracker.py
index 4d37a16..293be88 100644
--- a/ForgeImporters/forgeimporters/google/tracker.py
+++ b/ForgeImporters/forgeimporters/google/tracker.py
@@ -19,15 +19,14 @@ from collections import defaultdict
 from datetime import datetime
 
 from pylons import tmpl_context as c
-#import gdata
-gdata = None
-from ming.orm import session
+from ming.orm import session, ThreadLocalORMSession
 
 from allura.lib import helpers as h
 
 from forgetracker.tracker_main import ForgeTrackerApp
 from forgetracker import model as TM
 from ..base import ToolImporter
+from . import GoogleCodeProjectExtractor
 
 
 class GoogleCodeTrackerImporter(ToolImporter):
@@ -43,21 +42,20 @@ class GoogleCodeTrackerImporter(ToolImporter):
         )
 
     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']
+        c.app = project.install_app('tickets', mount_point, mount_label)
+        ThreadLocalORMSession.flush_all()
+        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():
+        for issue in GoogleCodeProjectExtractor.iter_issues(project_name):
             ticket = TM.Ticket.new()
             self.process_fields(ticket, issue)
             self.process_labels(ticket, issue)
-            self.process_comments(ticket, extractor.iter_comments(issue))
+            self.process_comments(ticket, 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)
+        ThreadLocalORMSession.flush_all()
 
     def custom_field(self, name):
         if name not in self.custom_fields:
@@ -70,16 +68,25 @@ class GoogleCodeTrackerImporter(ToolImporter):
         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, '')
+        ticket.summary = issue.get_issue_summary()
+        ticket.status = issue.get_issue_status()
+        ticket.created_date = datetime.strptime(issue.get_issue_created_date(), '%c')
+        ticket.mod_date = datetime.strptime(issue.get_issue_mod_date(), '%c')
+        ticket.description = (
+                u'*Originally created by:* [{creator.name}]({creator.link})\n'
+                '*Originally owned by:* [{owner.name}]({owner.link})\n'
+                '\n'
+                '{body}').format(
+                    creator=issue.get_issue_creator(),
+                    owner=issue.get_issue_owner(),
+                    body=issue.get_issue_description(),
+                )
+        ticket.add_multiple_attachments(issue.get_issue_attachments())
 
     def process_labels(self, ticket, issue):
         labels = set()
         custom_fields = defaultdict(set)
-        for label in issue.labels:
+        for label in issue.get_issue_labels():
             if u'-' in label:
                 name, value = label.split(u'-', 1)
                 cf = self.custom_field(name)
@@ -90,23 +97,24 @@ class GoogleCodeTrackerImporter(ToolImporter):
         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(
+    def process_comments(self, ticket, issue):
+        for comment in issue.iter_comments():
+            p = ticket.discussion_thread.add_post(
                     text = (
-                        u'Originally posted by: [{author.name}]({author.link})\n'
+                        u'*Originally posted by:* [{author.name}]({author.link})\n'
                         '\n'
                         '{body}\n'
                         '\n'
                         '{updates}').format(
                             author=comment.author,
-                            body=comment.text,
+                            body=comment.body,
                             updates='\n'.join(
-                                '*%s*: %s' % (k,v)
+                                '**%s** %s' % (k,v)
                                 for k,v in comment.updates.items()
                             ),
                     )
                 )
+            p.created_date = p.timestamp = datetime.strptime(comment.created_date, '%c')
             p.add_multiple_attachments(comment.attachments)
 
     def postprocess_custom_fields(self):
@@ -124,138 +132,3 @@ class GoogleCodeTrackerImporter(ToolImporter):
             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)
-            comments = client.get_comments(self.project_name, query=query).entry
-            if len(comments) <= 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/3ae77e93/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 1a3a87c..a2e4183 100644
--- a/ForgeImporters/forgeimporters/tests/google/test_extractor.py
+++ b/ForgeImporters/forgeimporters/tests/google/test_extractor.py
@@ -36,18 +36,17 @@ class TestGoogleCodeProjectExtractor(TestCase):
         self._p_soup.stop()
 
     def test_init(self):
-        extractor = google.GoogleCodeProjectExtractor(self.project, 'my-project', 'project_info')
+        extractor = google.GoogleCodeProjectExtractor('my-project', 'project_info')
 
         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, 'my-project', 'project_info')
+        extractor = google.GoogleCodeProjectExtractor('my-project', 'project_info')
         extractor.page.find.return_value.string = 'My Super Project'
 
-        extractor.get_short_description()
+        extractor.get_short_description(self.project)
 
         extractor.page.find.assert_called_once_with(itemprop='description')
         self.assertEqual(self.project.short_description, 'My Super Project')
@@ -56,11 +55,11 @@ 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, 'my-project', 'project_info')
+        extractor = google.GoogleCodeProjectExtractor('my-project', 'project_info')
         extractor.page.find.return_value.attrMap = {'src': 'http://example.com/foo/bar/my-logo.png'}
         self.urlopen.reset_mock()
 
-        extractor.get_icon()
+        extractor.get_icon(self.project)
 
         extractor.page.find.assert_called_once_with(itemprop='image')
         self.urlopen.assert_called_once_with('http://example.com/foo/bar/my-logo.png')
@@ -74,11 +73,11 @@ class TestGoogleCodeProjectExtractor(TestCase):
     @mock.patch.object(google, 'M')
     def test_get_license(self, M):
         self.project.trove_license = []
-        extractor = google.GoogleCodeProjectExtractor(self.project, 'my-project', 'project_info')
+        extractor = google.GoogleCodeProjectExtractor('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
 
-        extractor.get_license()
+        extractor.get_license(self.project)
 
         extractor.page.find.assert_called_once_with(text='Code license')
         extractor.page.find.return_value.findNext.assert_called_once_with()
@@ -88,13 +87,13 @@ class TestGoogleCodeProjectExtractor(TestCase):
 
         M.TroveCategory.query.get.reset_mock()
         extractor.page.find.return_value.findNext.return_value.find.return_value.string = 'non-existant license'
-        extractor.get_license()
+        extractor.get_license(self.project)
         M.TroveCategory.query.get.assert_called_once_with(fullname='Other/Proprietary License')
 
     def _make_extractor(self, html):
         from BeautifulSoup import BeautifulSoup
         with mock.patch.object(google, 'urllib2') as urllib2:
-            extractor = google.GoogleCodeProjectExtractor(self.project, 'my-project', 'project_info')
+            extractor = google.GoogleCodeProjectExtractor('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/3ae77e93/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 23da83f..131b1c3 100644
--- a/ForgeImporters/forgeimporters/tests/google/test_tasks.py
+++ b/ForgeImporters/forgeimporters/tests/google/test_tasks.py
@@ -26,10 +26,10 @@ from ...google import tasks
 def test_import_project_info(c, session, gpe):
     c.project = mock.Mock(name='project')
     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()
+    gpe.assert_called_once_with('my-project', 'project_info')
+    gpe.return_value.get_short_description.assert_called_once_with(c.project)
+    gpe.return_value.get_icon.assert_called_once_with(c.project)
+    gpe.return_value.get_license.assert_called_once_with(c.project)
     session.flush_all.assert_called_once_with()
 
 

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/3ae77e93/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
index d54ac90..0e27784 100644
--- a/ForgeImporters/forgeimporters/tests/google/test_tracker.py
+++ b/ForgeImporters/forgeimporters/tests/google/test_tracker.py
@@ -15,6 +15,7 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
+from datetime import datetime
 from operator import itemgetter
 from unittest import TestCase
 import mock
@@ -24,10 +25,11 @@ from ...google import tracker
 
 class TestTrackerImporter(TestCase):
     @mock.patch.object(tracker, 'c')
+    @mock.patch.object(tracker, 'ThreadLocalORMSession')
     @mock.patch.object(tracker, 'session')
     @mock.patch.object(tracker, 'TM')
-    @mock.patch.object(tracker, 'GDataAPIExtractor')
-    def test_import_tool(self, gdata, TM, session, c):
+    @mock.patch.object(tracker, 'GoogleCodeProjectExtractor')
+    def test_import_tool(self, gpe, TM, session, tlos, c):
         importer = tracker.GoogleCodeTrackerImporter()
         importer.process_fields = mock.Mock()
         importer.process_labels = mock.Mock()
@@ -35,15 +37,13 @@ class TestTrackerImporter(TestCase):
         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()]
+        issues = gpe.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')
+        project.install_app.assert_called_once_with('tickets', 'mount_point', 'mount_label')
+        gpe.iter_issues.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]),
@@ -53,26 +53,16 @@ class TestTrackerImporter(TestCase):
                 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]),
+                mock.call(tickets[0], issues[0]),
+                mock.call(tickets[1], issues[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(tlos.flush_all.call_args_list, [
+                mock.call(),
+                mock.call(),
             ])
         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]),
@@ -118,30 +108,37 @@ class TestTrackerImporter(TestCase):
 
     def test_process_fields(self):
         ticket = mock.Mock()
+        def _user(l):
+            u = mock.Mock()
+            u.name = '%sname' % l
+            u.link = '%slink' % l
+            return u
         issue = mock.Mock(
-                summary='summary',
-                description='description',
-                status='status',
-                created_date='created_date',
-                mod_date='mod_date',
+                get_issue_summary=lambda:'summary',
+                get_issue_description=lambda:'description',
+                get_issue_status=lambda:'status',
+                get_issue_created_date=lambda:'created_date',
+                get_issue_mod_date=lambda:'mod_date',
+                get_issue_creator=lambda:_user('c'),
+                get_issue_owner=lambda:_user('o'),
             )
         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.description, '*Originally created by:* [cname](clink)\n*Originally owned by:* [oname](olink)\n\ndescription')
             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', ''),
+                    mock.call('created_date', '%c'),
+                    mock.call('mod_date', '%c'),
                 ])
 
     def test_process_labels(self):
         ticket = mock.Mock(custom_fields={}, labels=[])
-        issue = mock.Mock(labels=['Foo-Bar', 'Baz', 'Foo-Qux'])
+        issue = mock.Mock(get_issue_labels=lambda:['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)
@@ -155,40 +152,49 @@ class TestTrackerImporter(TestCase):
             a.link = 'author%s_link' % n
             return a
         ticket = mock.Mock()
-        comments = [
+        issue = mock.Mock()
+        comments = issue.iter_comments.return_value = [
                 mock.Mock(
                     author=_author(1),
-                    text='text1',
+                    body='text1',
                     attachments='attachments1',
+                    created_date='Mon Jul 15 00:00:00 2013',
                 ),
                 mock.Mock(
                     author=_author(2),
-                    text='text2',
+                    body='text2',
                     attachments='attachments2',
+                    created_date='Mon Jul 16 00:00:00 2013',
                 ),
             ]
-        comments[0].updates.items.return_value = [('Foo', 'Bar'), ('Baz', 'Qux')]
+        comments[0].updates.items.return_value = [('Foo:', 'Bar'), ('Baz:', 'Qux')]
         comments[1].updates.items.return_value = []
+        posts = ticket.discussion_thread.add_post.side_effect = [
+                mock.Mock(),
+                mock.Mock(),
+            ]
         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'
+        importer.process_comments(ticket, issue)
+        self.assertEqual(ticket.discussion_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'
+                '**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'
+        self.assertEqual(posts[0].created_date, datetime(2013, 7, 15))
+        self.assertEqual(posts[0].timestamp, datetime(2013, 7, 15))
+        posts[0].add_multiple_attachments.assert_called_once_with('attachments1')
+        self.assertEqual(ticket.discussion_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'),
-            ])
+        self.assertEqual(posts[1].created_date, datetime(2013, 7, 16))
+        self.assertEqual(posts[1].timestamp, datetime(2013, 7, 16))
+        posts[1].add_multiple_attachments.assert_called_once_with('attachments2')
 
     @mock.patch.object(tracker, 'c')
     def test_postprocess_custom_fields(self, c):


[2/8] git commit: [#3154] ticket:391 ForgeBlog REST API

Posted by jo...@apache.org.
[#3154] ticket:391 ForgeBlog REST API


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

Branch: refs/heads/cj/6464
Commit: af5c8e683f96007b64108a0e89846c949e96ad3e
Parents: 0065ba1
Author: Yuriy Arhipov <yu...@yandex.ru>
Authored: Wed Jul 10 14:57:48 2013 +0400
Committer: Dave Brondsema <db...@slashdotmedia.com>
Committed: Wed Jul 31 21:00:24 2013 +0000

----------------------------------------------------------------------
 ForgeBlog/forgeblog/main.py                     |  87 +++++++++-
 ForgeBlog/forgeblog/model/blog.py               |   9 +
 .../forgeblog/tests/functional/test_rest.py     | 169 +++++++++++++++++++
 3 files changed, 264 insertions(+), 1 deletion(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/af5c8e68/ForgeBlog/forgeblog/main.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/main.py b/ForgeBlog/forgeblog/main.py
index 5f54a38..0f4e7e6 100644
--- a/ForgeBlog/forgeblog/main.py
+++ b/ForgeBlog/forgeblog/main.py
@@ -30,6 +30,7 @@ from paste.deploy.converters import asbool
 import formencode
 from formencode import validators
 from webob import exc
+from urllib import unquote
 
 from ming.orm import session
 
@@ -45,7 +46,7 @@ from allura.lib.widgets.subscriptions import SubscribeForm
 from allura.lib.widgets import form_fields as ffw
 from allura.lib.widgets.search import SearchResults, SearchHelp
 from allura import model as M
-from allura.controllers import BaseController, AppDiscussionController
+from allura.controllers import BaseController, AppDiscussionController, AppDiscussionRestController
 from allura.controllers.feed import FeedArgs, FeedController
 
 # Local imports
@@ -101,6 +102,7 @@ class ForgeBlogApp(Application):
         Application.__init__(self, project, config)
         self.root = RootController()
         self.admin = BlogAdminController(self)
+        self.api_root = RootRestController()
 
     @Property
     def external_feeds_list():
@@ -439,3 +441,86 @@ class BlogAdminController(DefaultAdminController):
             flash('Invalid link(s): %s' % ','.join(link for link in invalid_list), 'error')
 
         redirect(c.project.url()+'admin/tools')
+
+
+class RootRestController(BaseController):
+    def __init__(self):
+        self._discuss = AppDiscussionRestController()
+
+    def _check_security(self):
+        require_access(c.app, 'read')
+
+    @expose('json:')
+    def index(self, title='', text='', state='draft', labels='', **kw):
+        if request.method == 'POST':
+            require_access(c.app, 'write')
+            post = BM.BlogPost()
+            post.title = title
+            post.state = state
+            post.text = text
+            post.labels = labels.split(',')
+            post.neighborhood_id = c.project.neighborhood_id
+            post.make_slug()
+            M.Thread.new(discussion_id=post.app_config.discussion_id,
+                         ref_id=post.index_id(),
+                         subject='%s discussion' % post.title)
+
+            post.viewable_by = ['all']
+            post.commit()
+            return post.__json__()
+        else:
+            post_titles = []
+            query_filter = dict(app_config_id=c.app.config._id, deleted=False)
+            if not has_access(c.app, 'write')():
+                query_filter['state'] = 'published'
+            posts = BM.BlogPost.query.find(query_filter)
+            for post in posts:
+                if has_access(post, 'read')():
+                    post_titles.append({'title': post.title, 'url': h.absurl('/rest' + post.url())})
+            return dict(posts=post_titles)
+
+    @expose()
+    def _lookup(self, year=None, month=None, title=None, *rest):
+        if not (year and month and title):
+            raise exc.HTTPNotFound()
+        slug = '/'.join((year, month, urllib2.unquote(title).decode('utf-8')))
+        post = BM.BlogPost.query.get(slug=slug, app_config_id=c.app.config._id)
+        if not post:
+            raise exc.HTTPNotFound()
+        return PostRestController(post), rest
+
+
+class PostRestController(BaseController):
+
+    def __init__(self, post):
+        self.post = post
+
+    def _check_security(self):
+        if self.post:
+            require_access(self.post, 'read')
+
+    @h.vardec
+    @expose('json:')
+    def index(self, **kw):
+        if request.method == 'POST':
+            return self._update_post(**kw)
+        else:
+            if self.post.state == 'draft':
+                require_access(self.post, 'write')
+            return self.post.__json__()
+
+    def _update_post(self, **post_data):
+        require_access(self.post, 'write')
+        if 'delete' in post_data:
+            self.post.delete()
+            return {}
+        if 'title' in post_data:
+            self.post.title = post_data['title']
+        if 'text' in post_data:
+            self.post.text = post_data['text']
+        if 'state' in post_data:
+            self.post.state = post_data['state']
+        if 'labels' in post_data:
+            self.post.labels = post_data['labels'].split(',')
+        self.post.commit()
+        return self.post.__json__()
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/af5c8e68/ForgeBlog/forgeblog/model/blog.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/model/blog.py b/ForgeBlog/forgeblog/model/blog.py
index b43cb8e..07983a0 100644
--- a/ForgeBlog/forgeblog/model/blog.py
+++ b/ForgeBlog/forgeblog/model/blog.py
@@ -256,6 +256,15 @@ class BlogPost(M.VersionedArtifact, ActivityObject):
             M.Notification.post(
                 artifact=self, topic='metadata', text=description, subject=subject)
 
+    def __json__(self):
+        return dict(super(BlogPost, self).__json__(),
+                    title=self.title,
+                    url=h.absurl('/rest' + self.url()),
+                    text=self.text,
+                    labels=self.labels,
+                    state=self.state)
+
+
 class Attachment(M.BaseAttachment):
     ArtifactClass=BlogPost
     class __mongometa__:

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/af5c8e68/ForgeBlog/forgeblog/tests/functional/test_rest.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/tests/functional/test_rest.py b/ForgeBlog/forgeblog/tests/functional/test_rest.py
new file mode 100644
index 0000000..5addcde
--- /dev/null
+++ b/ForgeBlog/forgeblog/tests/functional/test_rest.py
@@ -0,0 +1,169 @@
+# coding: utf-8
+
+#       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 nose.tools import assert_equal
+from allura.lib import helpers as h
+from allura.tests import decorators as td
+from allura import model as M
+from alluratest.controller import TestRestApiBase
+from forgeblog import model as BM
+
+
+class TestBlogApi(TestRestApiBase):
+
+    def setUp(self):
+        super(TestBlogApi, self).setUp()
+        self.setup_with_tools()
+
+    @td.with_tool('test', 'Blog', 'blog')
+    def setup_with_tools(self):
+        h.set_context('test', 'blog', neighborhood='Projects')
+
+    def test_create_post(self):
+        data = {
+            'title': 'test',
+            'text': 'test text',
+            'state': 'published',
+            'labels': 'label1, label2'
+        }
+        r = self.api_post('/rest/p/test/blog/', **data)
+        assert_equal(r.status_int, 200)
+        url = '/rest' + BM.BlogPost.query.find().first().url()
+        r = self.api_get('/rest/p/test/blog/')
+        assert_equal(r.json['posts'][0]['title'], 'test')
+        assert_equal(r.json['posts'][0]['url'], h.absurl(url))
+
+        r = self.api_get(url)
+        assert_equal(r.json['title'], 'test')
+        assert_equal(r.json['text'], data['text'])
+        assert_equal(r.json['state'], data['state'])
+        assert_equal(r.json['labels'], data['labels'].split(','))
+
+    def test_update_post(self):
+        data = {
+            'title': 'test',
+            'text': 'test text',
+            'state': 'published',
+            'labels': 'label1, label2'
+        }
+        r = self.api_post('/rest/p/test/blog/', **data)
+        assert_equal(r.status_int, 200)
+        url = '/rest' + BM.BlogPost.query.find().first().url()
+        data = {
+            'text': 'test text2',
+            'state': 'draft',
+            'labels': 'label3'
+        }
+        self.api_post(url, **data)
+        r = self.api_get(url)
+        assert_equal(r.json['title'], 'test')
+        assert_equal(r.json['text'], data['text'])
+        assert_equal(r.json['state'], data['state'])
+        assert_equal(r.json['labels'], data['labels'].split(','))
+
+    def test_delete_post(self):
+        data = {
+            'title': 'test',
+            'state': 'published',
+            'labels': 'label1, label2'
+        }
+        r = self.api_post('/rest/p/test/blog/', **data)
+        assert_equal(r.status_int, 200)
+        url = '/rest' + BM.BlogPost.query.find().first().url()
+        self.api_post(url, delete='')
+        r = self.api_get(url)
+        assert_equal(r.status_int, 404)
+
+    def test_post_does_not_exist(self):
+        r = self.api_get('/rest/p/test/blog/2013/07/fake/')
+        assert_equal(r.status_int, 404)
+
+    def test_read_permissons(self):
+        self.api_post('/rest/p/test/blog/', title='test', text='test text', state='published')
+        self.app.get('/rest/p/test/blog/', extra_environ={'username': '*anonymous'}, status=200)
+        p = M.Project.query.get(shortname='test')
+        acl = p.app_instance('blog').config.acl
+        anon = M.ProjectRole.by_name('*anonymous')._id
+        anon_read = M.ACE.allow(anon, 'read')
+        acl.remove(anon_read)
+        self.app.get('/rest/p/test/blog/',
+                     extra_environ={'username': '*anonymous'},
+                     status=401)
+
+    def test_new_post_permissons(self):
+        self.app.post('/rest/p/test/blog/',
+                      params=dict(title='test', text='test text', state='published'),
+                      extra_environ={'username': '*anonymous'},
+                      status=401)
+        p = M.Project.query.get(shortname='test')
+        acl = p.app_instance('blog').config.acl
+        anon = M.ProjectRole.by_name('*anonymous')._id
+        anon_write = M.ACE.allow(anon, 'write')
+        acl.append(anon_write)
+        self.app.post('/rest/p/test/blog/',
+                      params=dict(title='test', text='test text', state='published'),
+                      extra_environ={'username': '*anonymous'},
+                      status=200)
+
+    def test_update_post_permissons(self):
+        self.api_post('/rest/p/test/blog/', title='test', text='test text', state='published')
+        url = '/rest' + BM.BlogPost.query.find().first().url()
+        self.app.post(url.encode('utf-8'),
+                      params=dict(title='test2', text='test text2', state='published'),
+                      extra_environ={'username': '*anonymous'},
+                      status=401)
+        p = M.Project.query.get(shortname='test')
+        acl = p.app_instance('blog').config.acl
+        anon = M.ProjectRole.by_name('*anonymous')._id
+        anon_write = M.ACE.allow(anon, 'write')
+        acl.append(anon_write)
+        self.app.post(url.encode('utf-8'),
+                      params=dict(title='test2', text='test text2', state='published'),
+                      extra_environ={'username': '*anonymous'},
+                      status=200)
+        r = self.api_get(url)
+        assert_equal(r.json['title'], 'test2')
+        assert_equal(r.json['text'], 'test text2')
+        assert_equal(r.json['state'], 'published')
+
+    def test_permission_draft_post(self):
+        self.api_post('/rest/p/test/blog/', title='test', text='test text', state='draft')
+        r = self.app.get('/rest/p/test/blog/', extra_environ={'username': '*anonymous'})
+        assert_equal(r.json, {'posts': []})
+        url = '/rest' + BM.BlogPost.query.find().first().url()
+        self.app.post(url.encode('utf-8'),
+                      params=dict(title='test2', text='test text2', state='published'),
+                      extra_environ={'username': '*anonymous'},
+                      status=401)
+        p = M.Project.query.get(shortname='test')
+        acl = p.app_instance('blog').config.acl
+        anon = M.ProjectRole.by_name('*anonymous')._id
+        anon_write = M.ACE.allow(anon, 'write')
+        acl.append(anon_write)
+        r = self.app.get('/rest/p/test/blog/', extra_environ={'username': '*anonymous'})
+        assert_equal(r.json['posts'][0]['title'], 'test')
+
+    def test_draft_post(self):
+        self.api_post('/rest/p/test/blog/', title='test', text='test text', state='draft')
+        r = self.app.get('/rest/p/test/blog/', extra_environ={'username': '*anonymous'})
+        assert_equal(r.json, {'posts': []})
+        url = '/rest' + BM.BlogPost.query.find().first().url()
+        self.api_post(url, state='published')
+        r = self.app.get('/rest/p/test/blog/', extra_environ={'username': '*anonymous'})
+        assert_equal(r.json['posts'][0]['title'], 'test')


[6/8] git commit: [#6461] remove gdata dep since it'll be replaced soon anyway

Posted by jo...@apache.org.
[#6461] remove gdata dep since it'll be replaced soon anyway


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

Branch: refs/heads/cj/6464
Commit: fb06265590066cd8748ae1355ec12db2b113c7dc
Parents: 9cc5c8f
Author: Dave Brondsema <db...@slashdotmedia.com>
Authored: Tue Aug 6 21:41:24 2013 +0000
Committer: Dave Brondsema <db...@slashdotmedia.com>
Committed: Tue Aug 6 21:49:17 2013 +0000

----------------------------------------------------------------------
 ForgeImporters/forgeimporters/google/tracker.py | 2 +-
 requirements-common.txt                         | 1 -
 2 files changed, 1 insertion(+), 2 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/fb062655/ForgeImporters/forgeimporters/google/tracker.py
----------------------------------------------------------------------
diff --git a/ForgeImporters/forgeimporters/google/tracker.py b/ForgeImporters/forgeimporters/google/tracker.py
index 602690e..4bc602c 100644
--- a/ForgeImporters/forgeimporters/google/tracker.py
+++ b/ForgeImporters/forgeimporters/google/tracker.py
@@ -19,7 +19,7 @@ from collections import defaultdict
 from datetime import datetime
 
 from pylons import tmpl_context as c
-import gdata
+#import gdata
 from ming.orm import session
 
 from allura.lib import helpers as h

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/fb062655/requirements-common.txt
----------------------------------------------------------------------
diff --git a/requirements-common.txt b/requirements-common.txt
index e7b7ef1..5e261a0 100644
--- a/requirements-common.txt
+++ b/requirements-common.txt
@@ -50,7 +50,6 @@ 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


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

Posted by jo...@apache.org.
[#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/9cc5c8ff
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/9cc5c8ff
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/9cc5c8ff

Branch: refs/heads/cj/6464
Commit: 9cc5c8ffb0824e46b4519c7a07fc9914d0568f3b
Parents: 93d12e6
Author: Cory Johns <cj...@slashdotmedia.com>
Authored: Mon Jul 29 00:15:16 2013 +0000
Committer: Dave Brondsema <db...@slashdotmedia.com>
Committed: Tue Aug 6 21:49:17 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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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/9cc5c8ff/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