You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by tv...@apache.org on 2013/04/30 19:44:58 UTC

[1/2] git commit: [#6172] Remove default TG file extension handling.

Updated Branches:
  refs/heads/tv/6172 [created] 6bf51e3f2


[#6172] Remove default TG file extension handling.

Repo names can now have dots in them, and TG was stripping the
dot and everything after it from the repo name in the url.
Rather than adding another hack to piece the URL back
together, we're turning off this behavior.

Some controllers were relying on the extension stripping to
automatically forward `feed.{rss|atom}` -> `feed`. In these cases,
explicit attrs for rss and atom were added to the controller.

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

Branch: refs/heads/tv/6172
Commit: bdfb4c8f89bc300915ed301d5cb7418f54c1a84c
Parents: 5fb297e
Author: Tim Van Steenburgh <tv...@gmail.com>
Authored: Tue Apr 30 13:45:41 2013 +0000
Committer: Tim Van Steenburgh <tv...@gmail.com>
Committed: Tue Apr 30 13:45:41 2013 +0000

----------------------------------------------------------------------
 Allura/allura/config/app_cfg.py                    |    1 +
 Allura/allura/controllers/discuss.py               |    4 ++++
 Allura/allura/controllers/repository.py            |   10 ++++------
 Allura/allura/ext/user_profile/user_main.py        |    4 ++++
 Allura/allura/lib/base.py                          |   15 +++++++++++++++
 Allura/allura/lib/patches.py                       |    8 ++++++--
 .../allura/tests/functional/test_user_profile.py   |    7 ++++---
 .../forgediscussion/controllers/root.py            |    4 ++++
 .../forgediscussion/tests/functional/test_forum.py |    4 +++-
 .../forgegit/tests/functional/test_controllers.py  |    3 ++-
 ForgeLink/forgelink/link_main.py                   |    5 -----
 ForgeSVN/forgesvn/svn_main.py                      |    2 ++
 .../forgesvn/tests/functional/test_controllers.py  |    4 ++--
 .../forgetracker/tests/functional/test_root.py     |    7 +++----
 ForgeTracker/forgetracker/tracker_main.py          |    2 ++
 ForgeWiki/forgewiki/tests/functional/test_root.py  |    4 ++++
 ForgeWiki/forgewiki/wiki_main.py                   |    5 -----
 17 files changed, 60 insertions(+), 29 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/Allura/allura/config/app_cfg.py
----------------------------------------------------------------------
diff --git a/Allura/allura/config/app_cfg.py b/Allura/allura/config/app_cfg.py
index c947fe1..f6b11e4 100644
--- a/Allura/allura/config/app_cfg.py
+++ b/Allura/allura/config/app_cfg.py
@@ -61,6 +61,7 @@ class ForgeConfig(AppConfig):
         self.use_transaction_manager = False
         # self.handle_status_codes = [ 403, 404 ]
         self.handle_status_codes = [ 403, 404 ]
+        self.disable_request_extensions = True
 
     def after_init_config(self):
         config['pylons.strict_c'] = True

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/Allura/allura/controllers/discuss.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/discuss.py b/Allura/allura/controllers/discuss.py
index 2ef1df4..7411da5 100644
--- a/Allura/allura/controllers/discuss.py
+++ b/Allura/allura/controllers/discuss.py
@@ -73,6 +73,8 @@ class DiscussionController(BaseController):
     W=WidgetConfig
 
     def __init__(self):
+        setattr(self, 'feed.rss', self.feed)
+        setattr(self, 'feed.atom', self.feed)
         if not hasattr(self, 'ThreadController'):
             self.ThreadController = ThreadController
         if not hasattr(self, 'PostController'):
@@ -168,6 +170,8 @@ class ThreadController(BaseController):
             require_access(self.thread.ref.artifact, 'read')
 
     def __init__(self, discussion_controller, thread_id):
+        setattr(self, 'feed.rss', self.feed)
+        setattr(self, 'feed.atom', self.feed)
         self._discussion_controller = discussion_controller
         self.discussion = discussion_controller.discussion
         self.thread = self.M.Thread.query.get(_id=thread_id)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/Allura/allura/controllers/repository.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/repository.py b/Allura/allura/controllers/repository.py
index 319506e..5e006ba 100644
--- a/Allura/allura/controllers/repository.py
+++ b/Allura/allura/controllers/repository.py
@@ -65,6 +65,10 @@ class RepoRootController(BaseController):
     _discuss = AppDiscussionController()
     commit_browser_widget=SCMCommitBrowserWidget()
 
+    def __init__(self):
+        setattr(self, 'feed.atom', self.feed)
+        setattr(self, 'feed.rss', self.feed)
+
     def _check_security(self):
         security.require(security.has_access(c.app, 'read'))
 
@@ -538,12 +542,6 @@ class TreeBrowser(BaseController, DispatchIndex):
 
     @expose()
     def _lookup(self, next, *rest):
-        if not rest and request.response_ext:
-            # Directory name may ends with file extension (e.g. `dir.rdf`)
-            # dispatching system will cut extension, so we need to restore it
-            next = "%s%s" % (next, request.response_ext)
-            request.response_ext = None
-            request.response_type = None
         next = h.really_unicode(unquote(next))
         if not rest:
             # Might be a file rather than a dir

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/Allura/allura/ext/user_profile/user_main.py
----------------------------------------------------------------------
diff --git a/Allura/allura/ext/user_profile/user_main.py b/Allura/allura/ext/user_profile/user_main.py
index bc2b942..0491199 100644
--- a/Allura/allura/ext/user_profile/user_main.py
+++ b/Allura/allura/ext/user_profile/user_main.py
@@ -81,6 +81,10 @@ class UserProfileApp(Application):
 
 class UserProfileController(BaseController):
 
+    def __init__(self):
+        setattr(self, 'feed.rss', self.feed)
+        setattr(self, 'feed.atom', self.feed)
+
     def _check_security(self):
         require_access(c.project, 'read')
 

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/Allura/allura/lib/base.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/base.py b/Allura/allura/lib/base.py
index 4bd5a63..409b77c 100644
--- a/Allura/allura/lib/base.py
+++ b/Allura/allura/lib/base.py
@@ -19,6 +19,7 @@
 
 """The base Controller API."""
 from webob import exc
+import pylons
 from tg import TGController, config
 
 __all__ = ['WsgiDispatchController']
@@ -51,4 +52,18 @@ class WsgiDispatchController(TGController):
         for chunk in response: yield chunk
         self._cleanup_request()
 
+    def _get_dispatchable(self, url_path):
+        """Patch ``TGController._get_dispatchable`` by overriding.
 
+        This fixes a bug in TG 2.1.5 that causes ``request.response_type``
+        to not be created if ``disable_request_extensions = True`` (see
+        allura/config/app_cfg.py).
+
+        ``request.response_type`` must be set because the "trailing slash"
+        decorators use it (see allura/lib/patches.py).
+
+        This entire method can be removed if/when we upgrade to TG >= 2.2.1
+
+        """
+        pylons.request.response_type = None
+        return super(WsgiDispatchController, self)._get_dispatchable(url_path)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/Allura/allura/lib/patches.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/patches.py b/Allura/allura/lib/patches.py
index adbc4bc..ef6fba5 100644
--- a/Allura/allura/lib/patches.py
+++ b/Allura/allura/lib/patches.py
@@ -61,7 +61,9 @@ def apply():
     @decorator
     def without_trailing_slash(func, *args, **kwargs):
         '''Monkey-patched to use 301 redirects for SEO'''
-        if request.method == 'GET' and request.path.endswith('/') and not(request.response_type) and len(request.params)==0:
+        response_type = getattr(request, 'response_type', None)
+        if (request.method == 'GET' and request.path.endswith('/')
+                and not response_type and len(request.params)==0):
             raise webob.exc.HTTPMovedPermanently(location=request.url[:-1])
         return func(*args, **kwargs)
 
@@ -69,7 +71,9 @@ def apply():
     @decorator
     def with_trailing_slash(func, *args, **kwargs):
         '''Monkey-patched to use 301 redirects for SEO'''
-        if request.method == 'GET' and not(request.path.endswith('/')) and not(request.response_type) and len(request.params)==0:
+        response_type = getattr(request, 'response_type', None)
+        if (request.method == 'GET' and not(request.path.endswith('/'))
+                and not response_type and len(request.params)==0):
             raise webob.exc.HTTPMovedPermanently(location=request.url+'/')
         return func(*args, **kwargs)
 

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/Allura/allura/tests/functional/test_user_profile.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/functional/test_user_profile.py b/Allura/allura/tests/functional/test_user_profile.py
index a8d724a..e5a9aef 100644
--- a/Allura/allura/tests/functional/test_user_profile.py
+++ b/Allura/allura/tests/functional/test_user_profile.py
@@ -53,6 +53,7 @@ class TestUserProfile(TestController):
     @td.with_user_project('test-admin')
     @td.with_wiki
     def test_feed(self):
-        response = self.app.get('/u/test-admin/profile/feed')
-        assert 'Recent posts by Test Admin' in response
-        assert 'Home modified by Test Admin' in response
+        for ext in ['', '.rss', '.atom']:
+            r = self.app.get('/u/test-admin/profile/feed%s' % ext, status=200)
+            assert 'Recent posts by Test Admin' in r
+            assert 'Home modified by Test Admin' in r

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeDiscussion/forgediscussion/controllers/root.py
----------------------------------------------------------------------
diff --git a/ForgeDiscussion/forgediscussion/controllers/root.py b/ForgeDiscussion/forgediscussion/controllers/root.py
index d9058fb..02c3cde 100644
--- a/ForgeDiscussion/forgediscussion/controllers/root.py
+++ b/ForgeDiscussion/forgediscussion/controllers/root.py
@@ -58,6 +58,10 @@ class RootController(BaseController, DispatchIndex):
         search_results = SearchResults()
         search_help = SearchHelp(comments=False, history=False)
 
+    def __init__(self):
+        setattr(self, 'feed.rss', self.feed)
+        setattr(self, 'feed.atom', self.feed)
+
     def _check_security(self):
         require_access(c.app, 'read')
 

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
----------------------------------------------------------------------
diff --git a/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py b/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
index e408189..73b6682 100644
--- a/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
+++ b/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
@@ -730,4 +730,6 @@ class TestForum(TestController):
         assert '<a href="flag_as_spam" class="sidebar_thread_spam"><b data-icon="^" class="ico ico-flag"></b> <span>Mark as Spam</span></a>' not in thread_sidebarmenu
 
     def test_feed(self):
-        r = self.app.get('/discussion/general/feed', status=200)
+        for ext in ['', '.rss', '.atom']:
+            self.app.get('/discussion/feed%s' % ext, status=200)
+            self.app.get('/discussion/general/feed%s' % ext, status=200)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeGit/forgegit/tests/functional/test_controllers.py
----------------------------------------------------------------------
diff --git a/ForgeGit/forgegit/tests/functional/test_controllers.py b/ForgeGit/forgegit/tests/functional/test_controllers.py
index 78a8d7e..186b46a 100644
--- a/ForgeGit/forgegit/tests/functional/test_controllers.py
+++ b/ForgeGit/forgegit/tests/functional/test_controllers.py
@@ -156,7 +156,8 @@ class TestRootController(_TestCase):
         assert 'Rick' in resp, resp.showbrowser()
 
     def test_feed(self):
-        assert 'Add README' in self.app.get('/feed')
+        for ext in ['', '.rss', '.atom']:
+            assert 'Add README' in self.app.get('/feed%s' % ext)
 
     def test_tree(self):
         ci = self._get_ci()

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeLink/forgelink/link_main.py
----------------------------------------------------------------------
diff --git a/ForgeLink/forgelink/link_main.py b/ForgeLink/forgelink/link_main.py
index 121c8cf..1f5fb2e 100644
--- a/ForgeLink/forgelink/link_main.py
+++ b/ForgeLink/forgelink/link_main.py
@@ -104,11 +104,6 @@ class RootController(BaseController):
     @expose()
     def _lookup(self, *remainder):
         path = "/".join(remainder)
-        # HACK: The TG request extension machinery will strip off the end of
-        # a dotted wiki page name if it matches a known file extension. Here,
-        # we reassemble the original page name.
-        if request.response_ext:
-            path += request.response_ext
         url = c.app.config.options.get('url')
         if url:
             permanent_redirect(url + h.really_unicode(path).encode('utf-8'))

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeSVN/forgesvn/svn_main.py
----------------------------------------------------------------------
diff --git a/ForgeSVN/forgesvn/svn_main.py b/ForgeSVN/forgesvn/svn_main.py
index cca94f7..ef7d008 100644
--- a/ForgeSVN/forgesvn/svn_main.py
+++ b/ForgeSVN/forgesvn/svn_main.py
@@ -65,6 +65,8 @@ class ForgeSVNApp(RepositoryApp):
         default_root = RepoRootController()
         self.root.refresh = default_root.refresh
         self.root.feed = default_root.feed
+        setattr(self.root, 'feed.rss', self.root.feed)
+        setattr(self.root, 'feed.atom', self.root.feed)
         self.root.commit_browser = default_root.commit_browser
         self.root.commit_browser_data = default_root.commit_browser_data
         self.root.status = default_root.status

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeSVN/forgesvn/tests/functional/test_controllers.py
----------------------------------------------------------------------
diff --git a/ForgeSVN/forgesvn/tests/functional/test_controllers.py b/ForgeSVN/forgesvn/tests/functional/test_controllers.py
index c5d340c..90af545 100644
--- a/ForgeSVN/forgesvn/tests/functional/test_controllers.py
+++ b/ForgeSVN/forgesvn/tests/functional/test_controllers.py
@@ -90,8 +90,8 @@ class TestRootController(SVNTestController):
                 assert val['message'] == 'Create readme'
 
     def test_feed(self):
-        r = self.app.get('/src/feed.rss')
-        assert 'Remove hello.txt' in str(r), r
+        for ext in ['', '.rss', '.atom']:
+            assert 'Remove hello.txt' in self.app.get('/src/feed%s' % ext)
 
     def test_commit(self):
         resp = self.app.get('/src/3/tree/')

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeTracker/forgetracker/tests/functional/test_root.py
----------------------------------------------------------------------
diff --git a/ForgeTracker/forgetracker/tests/functional/test_root.py b/ForgeTracker/forgetracker/tests/functional/test_root.py
index e193a10..dfaaba1 100644
--- a/ForgeTracker/forgetracker/tests/functional/test_root.py
+++ b/ForgeTracker/forgetracker/tests/functional/test_root.py
@@ -828,10 +828,9 @@ class TestFunctionalController(TrackerTestController):
         ThreadLocalORMSession.flush_all()
         M.MonQTask.run_ready()
         ThreadLocalORMSession.flush_all()
-        response = self.app.get('/p/test/bugs/search_feed?q=test')
-        assert '<title>test first ticket</title>' in response
-        response = self.app.get('/p/test/bugs/search_feed.atom?q=test')
-        assert '<title>test first ticket</title>' in response
+        for ext in ['', '.rss', '.atom']:
+            assert '<title>test first ticket</title>' in \
+                    self.app.get('/p/test/bugs/search_feed%s?q=test' % ext)
 
     def test_search_current_user(self):
         self.new_ticket(summary='test first ticket')

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeTracker/forgetracker/tracker_main.py
----------------------------------------------------------------------
diff --git a/ForgeTracker/forgetracker/tracker_main.py b/ForgeTracker/forgetracker/tracker_main.py
index 8d786d9..349ef1e 100644
--- a/ForgeTracker/forgetracker/tracker_main.py
+++ b/ForgeTracker/forgetracker/tracker_main.py
@@ -472,6 +472,8 @@ class RootController(BaseController):
     def __init__(self):
         setattr(self, 'feed.atom', self.feed)
         setattr(self, 'feed.rss', self.feed)
+        setattr(self, 'search_feed.atom', self.search_feed)
+        setattr(self, 'search_feed.rss', self.search_feed)
         self._discuss = AppDiscussionController()
 
     def _check_security(self):

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeWiki/forgewiki/tests/functional/test_root.py
----------------------------------------------------------------------
diff --git a/ForgeWiki/forgewiki/tests/functional/test_root.py b/ForgeWiki/forgewiki/tests/functional/test_root.py
index 14e70c6..d928e70 100644
--- a/ForgeWiki/forgewiki/tests/functional/test_root.py
+++ b/ForgeWiki/forgewiki/tests/functional/test_root.py
@@ -80,6 +80,10 @@ class TestRootController(TestController):
         response = self.app.get('/wiki/search?q=tést')
         assert 'Search wiki: tést' in response
 
+    def test_feed(self):
+        for ext in ['', '.rss', '.atom']:
+            self.app.get('/wiki/feed%s' % ext, status=200)
+
     @patch('allura.lib.search.search')
     def test_search(self, search):
         r = self.app.get('/wiki/search?q=test')

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/bdfb4c8f/ForgeWiki/forgewiki/wiki_main.py
----------------------------------------------------------------------
diff --git a/ForgeWiki/forgewiki/wiki_main.py b/ForgeWiki/forgewiki/wiki_main.py
index 23ca7f2..b923350 100644
--- a/ForgeWiki/forgewiki/wiki_main.py
+++ b/ForgeWiki/forgewiki/wiki_main.py
@@ -296,11 +296,6 @@ class RootController(BaseController, DispatchIndex):
     @expose()
     def _lookup(self, pname, *remainder):
         """Instantiate a Page object, and continue dispatch there."""
-        # HACK: The TG request extension machinery will strip off the end of
-        # a dotted wiki page name if it matches a known file extension. Here,
-        # we reassemble the original page name.
-        if request.response_ext and not remainder:
-            pname += request.response_ext
         return PageController(pname), remainder
 
     @expose()


[2/2] git commit: [#6172] Refactor feed functionality into mixin controller

Posted by tv...@apache.org.
[#6172] Refactor feed functionality into mixin controller

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

Branch: refs/heads/tv/6172
Commit: 6bf51e3f2fa229e3c6333d5f38f5ec125ce313b5
Parents: bdfb4c8
Author: Tim Van Steenburgh <tv...@gmail.com>
Authored: Tue Apr 30 17:44:37 2013 +0000
Committer: Tim Van Steenburgh <tv...@gmail.com>
Committed: Tue Apr 30 17:44:37 2013 +0000

----------------------------------------------------------------------
 Allura/allura/controllers/basetest_project_root.py |    2 -
 Allura/allura/controllers/discuss.py               |   75 ++++-------
 Allura/allura/controllers/feed.py                  |  108 +++++++++++++++
 Allura/allura/controllers/project.py               |   39 ++----
 Allura/allura/controllers/repository.py            |   31 +----
 Allura/allura/ext/user_profile/user_main.py        |   55 ++------
 ForgeBlog/forgeblog/main.py                        |   62 ++-------
 .../forgediscussion/controllers/root.py            |   44 ++----
 ForgeSVN/forgesvn/controllers.py                   |    3 +-
 ForgeSVN/forgesvn/svn_main.py                      |    3 -
 ForgeTracker/forgetracker/tracker_main.py          |   64 ++-------
 ForgeWiki/forgewiki/wiki_main.py                   |   67 ++-------
 12 files changed, 219 insertions(+), 334 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/Allura/allura/controllers/basetest_project_root.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/basetest_project_root.py b/Allura/allura/controllers/basetest_project_root.py
index f9ec3d5..8c48ed6 100644
--- a/Allura/allura/controllers/basetest_project_root.py
+++ b/Allura/allura/controllers/basetest_project_root.py
@@ -66,8 +66,6 @@ class BasetestProjectRootController(WsgiDispatchController, ProjectController):
     '''
 
     def __init__(self):
-        setattr(self, 'feed.rss', self.feed)
-        setattr(self, 'feed.atom', self.feed)
         for n in M.Neighborhood.query.find():
             if n.url_prefix.startswith('//'): continue
             n.bind_controller(self)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/Allura/allura/controllers/discuss.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/discuss.py b/Allura/allura/controllers/discuss.py
index 7411da5..2b1e297 100644
--- a/Allura/allura/controllers/discuss.py
+++ b/Allura/allura/controllers/discuss.py
@@ -38,6 +38,7 @@ from allura.lib.helpers import DateTimeConverter
 
 from allura.lib.widgets import discuss as DW
 from .attachments import AttachmentsController, AttachmentController
+from .feed import Feed, FeedController
 
 log = logging.getLogger(__name__)
 
@@ -68,13 +69,11 @@ class WidgetConfig(object):
     thread_header = DW.ThreadHeader()
 
 # Controllers
-class DiscussionController(BaseController):
+class DiscussionController(BaseController, FeedController):
     M=ModelConfig
     W=WidgetConfig
 
     def __init__(self):
-        setattr(self, 'feed.rss', self.feed)
-        setattr(self, 'feed.atom', self.feed)
         if not hasattr(self, 'ThreadController'):
             self.ThreadController = ThreadController
         if not hasattr(self, 'PostController'):
@@ -106,29 +105,18 @@ class DiscussionController(BaseController):
             M.session.artifact_orm_session._get().skip_mod_date = True
         redirect(request.referer)
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=DateTimeConverter(if_empty=None),
-            until=DateTimeConverter(if_empty=None),
-            page=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, page=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = 'Recent posts to %s' % self.discussion.name
-        feed = M.Feed.feed(
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.Feed` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
+        return Feed(
             dict(ref_id={'$in': [t.index_id() for t in self.discussion.threads]}),
-            feed_type,
-            title,
-            self.discussion.url(),
-            title,
-            since, until, page, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
+            'Recent posts to %s' % self.discussion.name,
+            self.discussion.url())
+
 
 class AppDiscussionController(DiscussionController):
 
@@ -156,7 +144,7 @@ class ThreadsController(BaseController):
         else:
             raise exc.HTTPNotFound()
 
-class ThreadController(BaseController):
+class ThreadController(BaseController, FeedController):
     __metaclass__=h.ProxiedAttrMeta
     M=h.attrproxy('_discussion_controller', 'M')
     W=h.attrproxy('_discussion_controller', 'W')
@@ -170,8 +158,6 @@ class ThreadController(BaseController):
             require_access(self.thread.ref.artifact, 'read')
 
     def __init__(self, discussion_controller, thread_id):
-        setattr(self, 'feed.rss', self.feed)
-        setattr(self, 'feed.atom', self.feed)
         self._discussion_controller = discussion_controller
         self.discussion = discussion_controller.discussion
         self.thread = self.M.Thread.query.get(_id=thread_id)
@@ -242,29 +228,18 @@ class ThreadController(BaseController):
         flash('Thread flagged as spam.')
         redirect(self.discussion.url())
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=DateTimeConverter(if_empty=None),
-            until=DateTimeConverter(if_empty=None),
-            page=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, page=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = 'Recent posts to %s' % (self.thread.subject or '(no subject)')
-        feed = M.Feed.feed(
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.Feed` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
+        return Feed(
             dict(ref_id=self.thread.index_id()),
-            feed_type,
-            title,
-            self.thread.url(),
-            title,
-            since, until, page, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
+            'Recent posts to %s' % (self.thread.subject or '(no subject)'),
+            self.thread.url())
+
 
 class PostController(BaseController):
     __metaclass__=h.ProxiedAttrMeta

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/Allura/allura/controllers/feed.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/feed.py b/Allura/allura/controllers/feed.py
new file mode 100644
index 0000000..f014b21
--- /dev/null
+++ b/Allura/allura/controllers/feed.py
@@ -0,0 +1,108 @@
+#       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 tg import expose, validate, request, response
+from tg.decorators import without_trailing_slash
+from formencode import validators as V
+from pylons import tmpl_context as c
+from webob import exc
+
+from allura import model as M
+from allura.lib import helpers as h
+
+
+class Feed(object):
+    """A facade for the arguments required by
+    :meth:`allura.model.artifact.Feed.feed`.
+
+    Used by :meth:`FeedController.feed` to create a real feed.
+
+    """
+    def __init__(self, query, title, url, description=None):
+        self.query = query
+        self.title = title
+        self.url = url
+        self.description = description or title
+
+
+class FeedController(object):
+    """Mixin class which adds RSS and Atom feed endpoints to an existing
+    controller.
+
+    Feeds will be accessible at the following URLs:
+
+        http://host/path/to/controller/feed -> RSS
+        http://host/path/to/controller/feed.rss -> RSS
+        http://host/path/to/controller/feed.atom -> Atom
+
+    A default feed is provided by :meth:`get_feed`. Subclasses that need
+    a customized feed should override :meth:`get_feed`.
+
+    """
+    FEED_TYPES = ['.atom', '.rss']
+    FEED_NAMES = ['feed{0}'.format(typ) for typ in FEED_TYPES]
+
+    def __getattr__(self, name):
+        if name in self.FEED_NAMES:
+            return self.feed
+        raise AttributeError(name)
+
+    def _get_feed_type(self, request):
+        for typ in self.FEED_TYPES:
+            if request.environ['PATH_INFO'].endswith(typ):
+                return typ.lstrip('.')
+        return 'rss'
+
+    @without_trailing_slash
+    @expose()
+    @validate(dict(
+            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
+            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
+            page=V.Int(if_empty=None),
+            limit=V.Int(if_empty=None)))
+    def feed(self, since=None, until=None, page=None, limit=None, **kw):
+        """Return a utf8-encoded XML feed (RSS or Atom) to the browser.
+        """
+        feed_def = self.get_feed(c.project, c.app, c.user)
+        if not feed_def:
+            raise exc.HTTPNotFound
+        feed = M.Feed.feed(
+            feed_def.query,
+            self._get_feed_type(request),
+            feed_def.title,
+            feed_def.url,
+            feed_def.description,
+            since, until, page, limit)
+        response.headers['Content-Type'] = ''
+        response.content_type = 'application/xml'
+        return feed.writeString('utf-8')
+
+    def get_feed(self, project, app, user):
+        """Return a default :class:`Feed` for this controller.
+
+        Subclasses should override to customize the feed.
+
+        :param project: :class:`allura.model.project.Project`
+        :param app: :class:`allura.app.Application`
+        :param user: :class:`allura.model.auth.User`
+        :rtype: :class:`Feed`
+
+        """
+        return Feed(
+            dict(project_id=project._id, app_config_id=app.config._id),
+            'Recent changes to %s' % app.config.options.mount_point,
+            app.url)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/Allura/allura/controllers/project.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/project.py b/Allura/allura/controllers/project.py
index 4f02632..3bfe0ca 100644
--- a/Allura/allura/controllers/project.py
+++ b/Allura/allura/controllers/project.py
@@ -40,6 +40,7 @@ from allura.lib import helpers as h
 from allura.lib import utils
 from allura.lib.decorators import require_post
 from allura.controllers.error import ErrorController
+from allura.controllers.feed import Feed, FeedController
 from allura.lib.security import require_access, has_access
 from allura.lib.security import RoleCache
 from allura.lib.widgets import forms as ff
@@ -299,11 +300,9 @@ class ToolListController(object):
                 if e.tool_name and e.tool_name.lower() == tool_name]
         return dict(entries=entries, type=entries[0].tool_name.capitalize() if entries else None)
 
-class ProjectController(object):
+class ProjectController(FeedController):
 
     def __init__(self):
-        setattr(self, 'feed.rss', self.feed)
-        setattr(self, 'feed.atom', self.feed)
         setattr(self, '_nav.json', self._nav)
         self.screenshot = ScreenshotsController()
         self._list = ToolListController()
@@ -367,29 +366,17 @@ class ProjectController(object):
         else:
             redirect(c.project.app_configs[0].options.mount_point + '/')
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            page=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, page=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = 'Recent changes to Project %s' % c.project.name
-        feed = M.Feed.feed(
-            dict(project_id=c.project._id),
-            feed_type,
-            title,
-            c.project.url(),
-            title,
-            since, until, page, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.Feed` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
+        return Feed(
+            dict(project_id=project._id),
+            'Recent changes to Project %s' % project.name,
+            project.url())
 
     @expose()
     def icon(self, **kw):

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/Allura/allura/controllers/repository.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/repository.py b/Allura/allura/controllers/repository.py
index 5e006ba..59b154e 100644
--- a/Allura/allura/controllers/repository.py
+++ b/Allura/allura/controllers/repository.py
@@ -49,6 +49,7 @@ from allura.lib.widgets.subscriptions import SubscribeForm
 from allura import model as M
 from allura.lib.widgets import form_fields as ffw
 from allura.controllers.base import DispatchIndex
+from allura.controllers.feed import FeedController
 from allura.lib.diff import HtmlSideBySideDiff
 from paste.deploy.converters import asbool
 from allura.app import SitemapEntry
@@ -61,14 +62,10 @@ def on_import():
     CommitBrowser.TreeBrowserClass = TreeBrowser
     TreeBrowser.FileBrowserClass = FileBrowser
 
-class RepoRootController(BaseController):
+class RepoRootController(BaseController, FeedController):
     _discuss = AppDiscussionController()
     commit_browser_widget=SCMCommitBrowserWidget()
 
-    def __init__(self):
-        setattr(self, 'feed.atom', self.feed)
-        setattr(self, 'feed.rss', self.feed)
-
     def _check_security(self):
         security.require(security.has_access(c.app, 'read'))
 
@@ -187,30 +184,6 @@ class RepoRootController(BaseController):
             redirect(mr.url())
 
     @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            offset=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, offset=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = 'Recent changes to %s' % c.app.config.options.mount_point
-        feed = M.Feed.feed(
-            dict(project_id=c.project._id,app_config_id=c.app.config._id),
-            feed_type,
-            title,
-            c.app.url,
-            title,
-            since, until, offset, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
-
-    @without_trailing_slash
     @expose('jinja:allura:templates/repo/commit_browser.html')
     def commit_browser(self):
         if not c.app.repo or c.app.repo.status != 'ready':

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/Allura/allura/ext/user_profile/user_main.py
----------------------------------------------------------------------
diff --git a/Allura/allura/ext/user_profile/user_main.py b/Allura/allura/ext/user_profile/user_main.py
index 0491199..c6e2402 100644
--- a/Allura/allura/ext/user_profile/user_main.py
+++ b/Allura/allura/ext/user_profile/user_main.py
@@ -32,6 +32,7 @@ from allura.lib.helpers import DateTimeConverter
 from allura.lib.security import require_access
 from allura.model import User, Feed, ACE
 from allura.controllers import BaseController
+from allura.controllers.feed import Feed, FeedController
 from allura.lib.decorators import require_post
 
 log = logging.getLogger(__name__)
@@ -79,11 +80,7 @@ class UserProfileApp(Application):
         pass
 
 
-class UserProfileController(BaseController):
-
-    def __init__(self):
-        setattr(self, 'feed.rss', self.feed)
-        setattr(self, 'feed.atom', self.feed)
+class UserProfileController(BaseController, FeedController):
 
     def _check_security(self):
         require_access(c.project, 'read')
@@ -94,38 +91,16 @@ class UserProfileController(BaseController):
         if not user:
             raise exc.HTTPNotFound()
         return dict(user=user)
-    # This will be fully implemented in a future iteration
-    # @expose('jinja:allura.ext.user_profile:templates/user_subscriptions.html')
-    # def subscriptions(self):
-    #     username = c.project.shortname.split('/')[1]
-    #     user = User.by_username(username)
-    #     subs = Subscriptions.query.find({'user_id':user._id}).all()
-    #     for sub in subs:
-    #         for s in sub.subscriptions:
-    #             r = g.solr_short_timeout.search(s.artifact_index_id)
-    #             print r.docs
-    #     return dict(user=user)
-
-    @expose()
-    @validate(dict(
-            since=DateTimeConverter(if_empty=None),
-            until=DateTimeConverter(if_empty=None),
-            page=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, page=None, limit=None, **kw):
-        user = c.project.user_project_of
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = 'Recent posts by %s' % user.display_name
-        feed = Feed.feed(
-            {'author_link':user.url()},
-            feed_type,
-            title,
-            c.project.url(),
-            title,
-            since, until, page, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
+
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.Feed` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
+        user = project.user_project_of
+        return Feed(
+            {'author_link': user.url()},
+            'Recent posts by %s' % user.display_name,
+            project.url())

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/ForgeBlog/forgeblog/main.py
----------------------------------------------------------------------
diff --git a/ForgeBlog/forgeblog/main.py b/ForgeBlog/forgeblog/main.py
index f745af9..0c7af13 100644
--- a/ForgeBlog/forgeblog/main.py
+++ b/ForgeBlog/forgeblog/main.py
@@ -46,6 +46,7 @@ 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.feed import Feed, FeedController
 
 # Local imports
 from forgeblog import model as BM
@@ -180,11 +181,9 @@ class ForgeBlogApp(Application):
         BM.BlogPostSnapshot.query.remove(dict(app_config_id=c.app.config._id))
         super(ForgeBlogApp, self).uninstall(project)
 
-class RootController(BaseController):
+class RootController(BaseController, FeedController):
 
     def __init__(self):
-        setattr(self, 'feed.atom', self.feed)
-        setattr(self, 'feed.rss', self.feed)
         self._discuss = AppDiscussionController()
 
     def _check_security(self):
@@ -255,30 +254,6 @@ class RootController(BaseController):
         redirect(h.really_unicode(post.url()).encode('utf-8'))
 
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            offset=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, offset=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = '%s - %s' % (c.project.name, c.app.config.options.mount_label)
-        feed = M.Feed.feed(
-            dict(project_id=c.project._id, app_config_id=c.app.config._id),
-            feed_type,
-            title,
-            c.app.url,
-            title,
-            since, until, offset, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
-
     @with_trailing_slash
     @expose('jinja:allura:templates/markdown_syntax_dialog.html')
     def markdown_syntax_dialog(self, **kw):
@@ -293,12 +268,10 @@ class RootController(BaseController):
             raise exc.HTTPNotFound()
         return PostController(post), rest
 
-class PostController(BaseController):
+class PostController(BaseController, FeedController):
 
     def __init__(self, post):
         self.post = post
-        setattr(self, 'feed.atom', self.feed)
-        setattr(self, 'feed.rss', self.feed)
 
     def _check_security(self):
         require_access(self.post, 'read')
@@ -380,28 +353,17 @@ class PostController(BaseController):
             self.post.unsubscribe()
         redirect(h.really_unicode(request.referer).encode('utf-8'))
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            offset=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, offset=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        feed = M.Feed.feed(
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.Feed` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
+        return Feed(
             dict(ref_id=self.post.index_id()),
-            feed_type,
-            'Recent changes to %s' % self.post.title,
-            self.post.url(),
             'Recent changes to %s' % self.post.title,
-            since, until, offset, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
+            self.post.url())
 
     def _get_version(self, version):
         if not version: return self.post

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/ForgeDiscussion/forgediscussion/controllers/root.py
----------------------------------------------------------------------
diff --git a/ForgeDiscussion/forgediscussion/controllers/root.py b/ForgeDiscussion/forgediscussion/controllers/root.py
index 02c3cde..f731da3 100644
--- a/ForgeDiscussion/forgediscussion/controllers/root.py
+++ b/ForgeDiscussion/forgediscussion/controllers/root.py
@@ -27,14 +27,13 @@ from pylons import request
 from formencode import validators
 from webob import exc
 
-
 from allura.lib.security import require_access, has_access, require_authenticated
-from allura.model import Feed
 from allura.lib.search import search_app
 from allura.lib import helpers as h
 from allura.lib.utils import AntiSpam
 from allura.lib.decorators import require_post
 from allura.controllers import BaseController, DispatchIndex
+from allura.controllers.feed import Feed, FeedController
 
 from .forum import ForumController
 from forgediscussion import import_support
@@ -48,7 +47,7 @@ from forgediscussion.widgets.admin import AddForumShort
 
 log = logging.getLogger(__name__)
 
-class RootController(BaseController, DispatchIndex):
+class RootController(BaseController, DispatchIndex, FeedController):
 
     class W(object):
         forum_subscription_form=FW.ForumSubscriptionForm()
@@ -58,10 +57,6 @@ class RootController(BaseController, DispatchIndex):
         search_results = SearchResults()
         search_help = SearchHelp(comments=False, history=False)
 
-    def __init__(self):
-        setattr(self, 'feed.rss', self.feed)
-        setattr(self, 'feed.atom', self.feed)
-
     def _check_security(self):
         require_access(c.app, 'read')
 
@@ -191,29 +186,18 @@ class RootController(BaseController, DispatchIndex):
                 obj['obj'].subscriptions.pop(str(c.user._id), None)
         redirect(request.referer)
 
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None),
-            until=h.DateTimeConverter(if_empty=None),
-            page=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, page=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = 'Recent posts to %s' % c.app.config.options.mount_label
-
-        feed = Feed.feed(
-            dict(project_id=c.project._id, app_config_id=c.app.config._id),
-            feed_type,
-            title,
-            c.app.url,
-            title,
-            since, until, page, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.Feed` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
+        return Feed(
+            dict(project_id=project._id, app_config_id=app.config._id),
+             'Recent posts to %s' % app.config.options.mount_label,
+            app.url)
+
 
 class RootRestController(BaseController):
 

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/ForgeSVN/forgesvn/controllers.py
----------------------------------------------------------------------
diff --git a/ForgeSVN/forgesvn/controllers.py b/ForgeSVN/forgesvn/controllers.py
index 2e39fc7..66709f0 100644
--- a/ForgeSVN/forgesvn/controllers.py
+++ b/ForgeSVN/forgesvn/controllers.py
@@ -20,9 +20,10 @@ from tg.decorators import with_trailing_slash
 from pylons import tmpl_context as c
 
 from allura.controllers import repository
+from allura.controllers.feed import FeedController
 
 
-class BranchBrowser(repository.BranchBrowser):
+class BranchBrowser(repository.BranchBrowser, FeedController):
 
     def __init__(self):
         super(BranchBrowser, self).__init__(None)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/ForgeSVN/forgesvn/svn_main.py
----------------------------------------------------------------------
diff --git a/ForgeSVN/forgesvn/svn_main.py b/ForgeSVN/forgesvn/svn_main.py
index ef7d008..dcb4fd6 100644
--- a/ForgeSVN/forgesvn/svn_main.py
+++ b/ForgeSVN/forgesvn/svn_main.py
@@ -64,9 +64,6 @@ class ForgeSVNApp(RepositoryApp):
         self.root = BranchBrowser()
         default_root = RepoRootController()
         self.root.refresh = default_root.refresh
-        self.root.feed = default_root.feed
-        setattr(self.root, 'feed.rss', self.root.feed)
-        setattr(self.root, 'feed.atom', self.root.feed)
         self.root.commit_browser = default_root.commit_browser
         self.root.commit_browser_data = default_root.commit_browser_data
         self.root.status = default_root.status

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/ForgeTracker/forgetracker/tracker_main.py
----------------------------------------------------------------------
diff --git a/ForgeTracker/forgetracker/tracker_main.py b/ForgeTracker/forgetracker/tracker_main.py
index 349ef1e..6270e96 100644
--- a/ForgeTracker/forgetracker/tracker_main.py
+++ b/ForgeTracker/forgetracker/tracker_main.py
@@ -61,6 +61,7 @@ from allura.lib.zarkov_helpers import zero_fill_zarkov_result
 from allura.controllers import AppDiscussionController, AppDiscussionRestController
 from allura.controllers import attachments as ac
 from allura.controllers import BaseController
+from allura.controllers.feed import Feed, FeedController
 from allura.tasks import mail_tasks
 
 # Local imports
@@ -467,11 +468,9 @@ def solr_columns():
         columns.append(dict(name='votes', sort_name='votes_total_i', label='Votes', active=True))
     return columns
 
-class RootController(BaseController):
+class RootController(BaseController, FeedController):
 
     def __init__(self):
-        setattr(self, 'feed.atom', self.feed)
-        setattr(self, 'feed.rss', self.feed)
         setattr(self, 'search_feed.atom', self.search_feed)
         setattr(self, 'search_feed.rss', self.search_feed)
         self._discuss = AppDiscussionController()
@@ -711,30 +710,6 @@ class RootController(BaseController):
         'Static page explaining markdown.'
         return dict()
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            offset=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, offset=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = 'Recent changes to %s' % c.app.config.options.mount_point
-        feed = M.Feed.feed(
-            dict(project_id=c.project._id,app_config_id=c.app.config._id),
-            feed_type,
-            title,
-            c.app.url,
-            title,
-            since, until, offset, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
-
     @expose()
     @h.vardec
     @require_post()
@@ -1233,7 +1208,7 @@ def filtered_by_subscription(tickets, project_id=None, app_config_id=None):
     return filtered
 
 
-class TicketController(BaseController):
+class TicketController(BaseController, FeedController):
 
     def __init__(self, ticket_num=None):
         if ticket_num is not None:
@@ -1248,8 +1223,6 @@ class TicketController(BaseController):
                     utils.permanent_redirect(self.ticket.url())
             self.attachment = AttachmentsController(self.ticket)
             # self.comments = CommentController(self.ticket)
-        setattr(self, 'feed.atom', self.feed)
-        setattr(self, 'feed.rss', self.feed)
 
     def _check_security(self):
         if self.ticket is not None:
@@ -1285,30 +1258,19 @@ class TicketController(BaseController):
         else:
             raise exc.HTTPNotFound, 'Ticket #%s does not exist.' % self.ticket_num
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            offset=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, offset=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.Feed` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
         title = 'Recent changes to %d: %s' % (
             self.ticket.ticket_num, self.ticket.summary)
-        feed = M.Feed.feed(
-            {'ref_id':self.ticket.index_id()},
-            feed_type,
-            title,
-            self.ticket.url(),
+        return Feed(
+            {'ref_id': self.ticket.index_id()},
             title,
-            since, until, offset, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
+            self.ticket.url())
 
     @expose()
     @require_post()

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/6bf51e3f/ForgeWiki/forgewiki/wiki_main.py
----------------------------------------------------------------------
diff --git a/ForgeWiki/forgewiki/wiki_main.py b/ForgeWiki/forgewiki/wiki_main.py
index b923350..dfb982b 100644
--- a/ForgeWiki/forgewiki/wiki_main.py
+++ b/ForgeWiki/forgewiki/wiki_main.py
@@ -41,6 +41,7 @@ from allura.lib.security import require_access, has_access
 from allura.controllers import AppDiscussionController, BaseController
 from allura.controllers import DispatchIndex
 from allura.controllers import attachments as ac
+from allura.controllers.feed import Feed, FeedController
 from allura.lib import widgets as w
 from allura.lib.widgets import form_fields as ffw
 from allura.lib.widgets.subscriptions import SubscribeForm
@@ -277,11 +278,9 @@ The wiki uses [Markdown](%s) syntax.
         WM.Globals.query.remove(dict(app_config_id=self.config._id))
         super(ForgeWikiApp, self).uninstall(project)
 
-class RootController(BaseController, DispatchIndex):
+class RootController(BaseController, DispatchIndex, FeedController):
 
     def __init__(self):
-        setattr(self, 'feed.atom', self.feed)
-        setattr(self, 'feed.rss', self.feed)
         c.create_page_lightbox = W.create_page_lightbox
         self._discuss = AppDiscussionController()
 
@@ -409,31 +408,8 @@ class RootController(BaseController, DispatchIndex):
         'Display a page about how to use markdown.'
         return dict(example=MARKDOWN_EXAMPLE)
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            offset=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, offset=None, limit=None, **kw):
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        title = 'Recent changes to %s' % c.app.config.options.mount_point
-        feed = M.Feed.feed(
-            dict(project_id=c.project._id,app_config_id=c.app.config._id),
-            feed_type,
-            title,
-            c.app.url,
-            title,
-            since, until, offset, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
-
-class PageController(BaseController):
+
+class PageController(BaseController, FeedController):
 
     def __init__(self, title):
         self.title = h.really_unicode(unquote(title))
@@ -442,8 +418,6 @@ class PageController(BaseController):
         if self.page is not None:
             self.attachment = WikiAttachmentsController(self.page)
         c.create_page_lightbox = W.create_page_lightbox
-        setattr(self, 'feed.atom', self.feed)
-        setattr(self, 'feed.rss', self.feed)
 
     def _check_security(self):
         if self.page:
@@ -583,30 +557,19 @@ class PageController(BaseController):
             raise exc.HTTPNotFound
         return pformat(self.page)
 
-    @without_trailing_slash
-    @expose()
-    @validate(dict(
-            since=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            until=h.DateTimeConverter(if_empty=None, if_invalid=None),
-            offset=validators.Int(if_empty=None),
-            limit=validators.Int(if_empty=None)))
-    def feed(self, since=None, until=None, offset=None, limit=None, **kw):
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.Feed` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
         if not self.page:
-            raise exc.HTTPNotFound
-        if request.environ['PATH_INFO'].endswith('.atom'):
-            feed_type = 'atom'
-        else:
-            feed_type = 'rss'
-        feed = M.Feed.feed(
-            {'ref_id':self.page.index_id()},
-            feed_type,
-            'Recent changes to %s' % self.page.title,
-            self.page.url(),
+            return None
+        return Feed(
+            {'ref_id': self.page.index_id()},
             'Recent changes to %s' % self.page.title,
-            since, until, offset, limit)
-        response.headers['Content-Type'] = ''
-        response.content_type = 'application/xml'
-        return feed.writeString('utf-8')
+            self.page.url())
 
     @without_trailing_slash
     @expose('json')