Show More
@@ -0,0 +1,203 b'' | |||
|
1 | # -*- coding: utf-8 -*- | |
|
2 | ||
|
3 | # Copyright (C) 2017-2017 RhodeCode GmbH | |
|
4 | # | |
|
5 | # This program is free software: you can redistribute it and/or modify | |
|
6 | # it under the terms of the GNU Affero General Public License, version 3 | |
|
7 | # (only), as published by the Free Software Foundation. | |
|
8 | # | |
|
9 | # This program is distributed in the hope that it will be useful, | |
|
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
|
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
|
12 | # GNU General Public License for more details. | |
|
13 | # | |
|
14 | # You should have received a copy of the GNU Affero General Public License | |
|
15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
|
16 | # | |
|
17 | # This program is dual-licensed. If you wish to learn more about the | |
|
18 | # RhodeCode Enterprise Edition, including its added features, Support services, | |
|
19 | # and proprietary license terms, please see https://rhodecode.com/licenses/ | |
|
20 | ||
|
21 | import pytz | |
|
22 | import logging | |
|
23 | ||
|
24 | from beaker.cache import cache_region | |
|
25 | from pyramid.view import view_config | |
|
26 | from pyramid.response import Response | |
|
27 | from webhelpers.feedgenerator import Rss201rev2Feed, Atom1Feed | |
|
28 | ||
|
29 | from rhodecode.apps._base import RepoAppView | |
|
30 | from rhodecode.lib import audit_logger | |
|
31 | from rhodecode.lib import helpers as h | |
|
32 | from rhodecode.lib.auth import (LoginRequired, HasRepoPermissionAnyDecorator, | |
|
33 | NotAnonymous, CSRFRequired) | |
|
34 | from rhodecode.lib.diffs import DiffProcessor, LimitedDiffContainer | |
|
35 | from rhodecode.lib.ext_json import json | |
|
36 | from rhodecode.lib.utils2 import str2bool, safe_int | |
|
37 | from rhodecode.model.db import UserApiKeys, CacheKey | |
|
38 | ||
|
39 | log = logging.getLogger(__name__) | |
|
40 | ||
|
41 | ||
|
42 | class RepoFeedView(RepoAppView): | |
|
43 | def load_default_context(self): | |
|
44 | c = self._get_local_tmpl_context() | |
|
45 | ||
|
46 | # TODO(marcink): remove repo_info and use c.rhodecode_db_repo instead | |
|
47 | c.repo_info = self.db_repo | |
|
48 | ||
|
49 | self._register_global_c(c) | |
|
50 | self._load_defaults() | |
|
51 | return c | |
|
52 | ||
|
53 | def _get_config(self): | |
|
54 | import rhodecode | |
|
55 | config = rhodecode.CONFIG | |
|
56 | ||
|
57 | return { | |
|
58 | 'language': 'en-us', | |
|
59 | 'feed_ttl': '5', # TTL of feed, | |
|
60 | 'feed_include_diff': | |
|
61 | str2bool(config.get('rss_include_diff', False)), | |
|
62 | 'feed_items_per_page': | |
|
63 | safe_int(config.get('rss_items_per_page', 20)), | |
|
64 | 'feed_diff_limit': | |
|
65 | # we need to protect from parsing huge diffs here other way | |
|
66 | # we can kill the server | |
|
67 | safe_int(config.get('rss_cut_off_limit', 32 * 1024)), | |
|
68 | } | |
|
69 | ||
|
70 | def _load_defaults(self): | |
|
71 | _ = self.request.translate | |
|
72 | config = self._get_config() | |
|
73 | # common values for feeds | |
|
74 | self.description = _('Changes on %s repository') | |
|
75 | self.title = self.title = _('%s %s feed') % (self.db_repo_name, '%s') | |
|
76 | self.language = config["language"] | |
|
77 | self.ttl = config["feed_ttl"] | |
|
78 | self.feed_include_diff = config['feed_include_diff'] | |
|
79 | self.feed_diff_limit = config['feed_diff_limit'] | |
|
80 | self.feed_items_per_page = config['feed_items_per_page'] | |
|
81 | ||
|
82 | def _changes(self, commit): | |
|
83 | diff_processor = DiffProcessor( | |
|
84 | commit.diff(), diff_limit=self.feed_diff_limit) | |
|
85 | _parsed = diff_processor.prepare(inline_diff=False) | |
|
86 | limited_diff = isinstance(_parsed, LimitedDiffContainer) | |
|
87 | ||
|
88 | return _parsed, limited_diff | |
|
89 | ||
|
90 | def _get_title(self, commit): | |
|
91 | return h.shorter(commit.message, 160) | |
|
92 | ||
|
93 | def _get_description(self, commit): | |
|
94 | _renderer = self.request.get_partial_renderer( | |
|
95 | 'feed/atom_feed_entry.mako') | |
|
96 | parsed_diff, limited_diff = self._changes(commit) | |
|
97 | return _renderer( | |
|
98 | 'body', | |
|
99 | commit=commit, | |
|
100 | parsed_diff=parsed_diff, | |
|
101 | limited_diff=limited_diff, | |
|
102 | feed_include_diff=self.feed_include_diff, | |
|
103 | ) | |
|
104 | ||
|
105 | def _set_timezone(self, date, tzinfo=pytz.utc): | |
|
106 | if not getattr(date, "tzinfo", None): | |
|
107 | date.replace(tzinfo=tzinfo) | |
|
108 | return date | |
|
109 | ||
|
110 | def _get_commits(self): | |
|
111 | return list(self.rhodecode_vcs_repo[-self.feed_items_per_page:]) | |
|
112 | ||
|
113 | @LoginRequired(auth_token_access=[UserApiKeys.ROLE_FEED]) | |
|
114 | @HasRepoPermissionAnyDecorator( | |
|
115 | 'repository.read', 'repository.write', 'repository.admin') | |
|
116 | @view_config( | |
|
117 | route_name='atom_feed_home', request_method='GET', | |
|
118 | renderer=None) | |
|
119 | def atom(self): | |
|
120 | """ | |
|
121 | Produce an atom-1.0 feed via feedgenerator module | |
|
122 | """ | |
|
123 | self.load_default_context() | |
|
124 | ||
|
125 | @cache_region('long_term') | |
|
126 | def _generate_feed(cache_key): | |
|
127 | feed = Atom1Feed( | |
|
128 | title=self.title % self.db_repo_name, | |
|
129 | link=h.route_url('repo_summary', repo_name=self.db_repo_name), | |
|
130 | description=self.description % self.db_repo_name, | |
|
131 | language=self.language, | |
|
132 | ttl=self.ttl | |
|
133 | ) | |
|
134 | ||
|
135 | for commit in reversed(self._get_commits()): | |
|
136 | date = self._set_timezone(commit.date) | |
|
137 | feed.add_item( | |
|
138 | title=self._get_title(commit), | |
|
139 | author_name=commit.author, | |
|
140 | description=self._get_description(commit), | |
|
141 | link=h.route_url( | |
|
142 | 'changeset_home', repo_name=self.db_repo_name, | |
|
143 | revision=commit.raw_id), | |
|
144 | pubdate=date,) | |
|
145 | ||
|
146 | return feed.mime_type, feed.writeString('utf-8') | |
|
147 | ||
|
148 | invalidator_context = CacheKey.repo_context_cache( | |
|
149 | _generate_feed, self.db_repo_name, CacheKey.CACHE_TYPE_ATOM) | |
|
150 | ||
|
151 | with invalidator_context as context: | |
|
152 | context.invalidate() | |
|
153 | mime_type, feed = context.compute() | |
|
154 | ||
|
155 | response = Response(feed) | |
|
156 | response.content_type = mime_type | |
|
157 | return response | |
|
158 | ||
|
159 | @LoginRequired(auth_token_access=[UserApiKeys.ROLE_FEED]) | |
|
160 | @HasRepoPermissionAnyDecorator( | |
|
161 | 'repository.read', 'repository.write', 'repository.admin') | |
|
162 | @view_config( | |
|
163 | route_name='rss_feed_home', request_method='GET', | |
|
164 | renderer=None) | |
|
165 | def rss(self): | |
|
166 | """ | |
|
167 | Produce an rss2 feed via feedgenerator module | |
|
168 | """ | |
|
169 | self.load_default_context() | |
|
170 | ||
|
171 | @cache_region('long_term') | |
|
172 | def _generate_feed(cache_key): | |
|
173 | feed = Rss201rev2Feed( | |
|
174 | title=self.title % self.db_repo_name, | |
|
175 | link=h.route_url('repo_summary', repo_name=self.db_repo_name), | |
|
176 | description=self.description % self.db_repo_name, | |
|
177 | language=self.language, | |
|
178 | ttl=self.ttl | |
|
179 | ) | |
|
180 | ||
|
181 | for commit in reversed(self._get_commits()): | |
|
182 | date = self._set_timezone(commit.date) | |
|
183 | feed.add_item( | |
|
184 | title=self._get_title(commit), | |
|
185 | author_name=commit.author, | |
|
186 | description=self._get_description(commit), | |
|
187 | link=h.route_url( | |
|
188 | 'changeset_home', repo_name=self.db_repo_name, | |
|
189 | revision=commit.raw_id), | |
|
190 | pubdate=date,) | |
|
191 | ||
|
192 | return feed.mime_type, feed.writeString('utf-8') | |
|
193 | ||
|
194 | invalidator_context = CacheKey.repo_context_cache( | |
|
195 | _generate_feed, self.db_repo_name, CacheKey.CACHE_TYPE_RSS) | |
|
196 | ||
|
197 | with invalidator_context as context: | |
|
198 | context.invalidate() | |
|
199 | mime_type, feed = context.compute() | |
|
200 | ||
|
201 | response = Response(feed) | |
|
202 | response.content_type = mime_type | |
|
203 | return response |
@@ -80,6 +80,21 b' def includeme(config):' | |||
|
80 | 80 | pattern='/{repo_name:.*?[^/]}/pull-request-data', |
|
81 | 81 | repo_route=True, repo_accepted_types=['hg', 'git']) |
|
82 | 82 | |
|
83 | # commits aka changesets | |
|
84 | # TODO(dan): handle default landing revision ? | |
|
85 | config.add_route( | |
|
86 | name='changeset_home', | |
|
87 | pattern='/{repo_name:.*?[^/]}/changeset/{revision}', | |
|
88 | repo_route=True) | |
|
89 | config.add_route( | |
|
90 | name='changeset_children', | |
|
91 | pattern='/{repo_name:.*?[^/]}/changeset_children/{revision}', | |
|
92 | repo_route=True) | |
|
93 | config.add_route( | |
|
94 | name='changeset_parents', | |
|
95 | pattern='/{repo_name:.*?[^/]}/changeset_parents/{revision}', | |
|
96 | repo_route=True) | |
|
97 | ||
|
83 | 98 | # Settings |
|
84 | 99 | config.add_route( |
|
85 | 100 | name='edit_repo', |
@@ -143,6 +158,15 b' def includeme(config):' | |||
|
143 | 158 | name='strip_execute', |
|
144 | 159 | pattern='/{repo_name:.*?[^/]}/settings/strip_execute', repo_route=True) |
|
145 | 160 | |
|
161 | # ATOM/RSS Feed | |
|
162 | config.add_route( | |
|
163 | name='rss_feed_home', | |
|
164 | pattern='/{repo_name:.*?[^/]}/feed/rss', repo_route=True) | |
|
165 | ||
|
166 | config.add_route( | |
|
167 | name='atom_feed_home', | |
|
168 | pattern='/{repo_name:.*?[^/]}/feed/atom', repo_route=True) | |
|
169 | ||
|
146 | 170 | # NOTE(marcink): needs to be at the end for catch-all |
|
147 | 171 | add_route_with_slash( |
|
148 | 172 | config, |
@@ -17,59 +17,78 b'' | |||
|
17 | 17 | # This program is dual-licensed. If you wish to learn more about the |
|
18 | 18 | # RhodeCode Enterprise Edition, including its added features, Support services, |
|
19 | 19 | # and proprietary license terms, please see https://rhodecode.com/licenses/ |
|
20 | import pytest | |
|
20 | 21 | from rhodecode.model.auth_token import AuthTokenModel |
|
21 |
from rhodecode. |
|
|
22 | from rhodecode.tests import * | |
|
22 | from rhodecode.tests import TestController | |
|
23 | ||
|
24 | ||
|
25 | def route_path(name, params=None, **kwargs): | |
|
26 | import urllib | |
|
27 | ||
|
28 | base_url = { | |
|
29 | 'rss_feed_home': '/{repo_name}/feed/rss', | |
|
30 | 'atom_feed_home': '/{repo_name}/feed/atom', | |
|
31 | }[name].format(**kwargs) | |
|
32 | ||
|
33 | if params: | |
|
34 | base_url = '{}?{}'.format(base_url, urllib.urlencode(params)) | |
|
35 | return base_url | |
|
23 | 36 | |
|
24 | 37 | |
|
25 |
class TestFeed |
|
|
38 | class TestFeedView(TestController): | |
|
26 | 39 | |
|
27 | def test_rss(self, backend): | |
|
40 | @pytest.mark.parametrize("feed_type,response_types,content_type",[ | |
|
41 | ('rss', ['<rss version="2.0">'], | |
|
42 | "application/rss+xml"), | |
|
43 | ('atom', ['<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-us">'], | |
|
44 | "application/atom+xml"), | |
|
45 | ]) | |
|
46 | def test_feed(self, backend, feed_type, response_types, content_type): | |
|
28 | 47 | self.log_user() |
|
29 |
response = self.app.get( |
|
|
30 | repo_name=backend.repo_name)) | |
|
48 | response = self.app.get( | |
|
49 | route_path('{}_feed_home'.format(feed_type), repo_name=backend.repo_name)) | |
|
50 | ||
|
51 | for content in response_types: | |
|
52 | assert content in response | |
|
31 | 53 | |
|
32 |
assert response.content_type == |
|
|
33 | assert """<rss version="2.0">""" in response | |
|
54 | assert response.content_type == content_type | |
|
34 | 55 | |
|
35 | def test_rss_with_auth_token(self, backend, user_admin): | |
|
56 | @pytest.mark.parametrize("feed_type, content_type", [ | |
|
57 | ('rss', "application/rss+xml"), | |
|
58 | ('atom', "application/atom+xml") | |
|
59 | ]) | |
|
60 | def test_feed_with_auth_token( | |
|
61 | self, backend, user_admin, feed_type, content_type): | |
|
36 | 62 | auth_token = user_admin.feed_token |
|
37 | 63 | assert auth_token != '' |
|
38 | response = self.app.get( | |
|
39 | url(controller='feed', action='rss', | |
|
40 | repo_name=backend.repo_name, auth_token=auth_token, | |
|
41 | status=200)) | |
|
42 | 64 | |
|
43 | assert response.content_type == "application/rss+xml" | |
|
44 | assert """<rss version="2.0">""" in response | |
|
65 | response = self.app.get( | |
|
66 | route_path( | |
|
67 | '{}_feed_home'.format(feed_type), repo_name=backend.repo_name, | |
|
68 | params=dict(auth_token=auth_token)), | |
|
69 | status=200) | |
|
45 | 70 | |
|
46 | def test_rss_with_auth_token_of_wrong_type(self, backend, user_util): | |
|
71 | assert response.content_type == content_type | |
|
72 | ||
|
73 | @pytest.mark.parametrize("feed_type", ['rss', 'atom']) | |
|
74 | def test_feed_with_auth_token_of_wrong_type( | |
|
75 | self, backend, user_util, feed_type): | |
|
47 | 76 | user = user_util.create_user() |
|
48 | 77 | auth_token = AuthTokenModel().create( |
|
49 | 78 | user.user_id, 'test-token', -1, AuthTokenModel.cls.ROLE_API) |
|
50 | 79 | auth_token = auth_token.api_key |
|
51 | 80 | |
|
52 | 81 | self.app.get( |
|
53 | url(controller='feed', action='rss', | |
|
54 |
repo_name=backend.repo_name, |
|
|
82 | route_path( | |
|
83 | '{}_feed_home'.format(feed_type), repo_name=backend.repo_name, | |
|
84 | params=dict(auth_token=auth_token)), | |
|
55 | 85 | status=302) |
|
56 | 86 | |
|
57 | 87 | auth_token = AuthTokenModel().create( |
|
58 | 88 | user.user_id, 'test-token', -1, AuthTokenModel.cls.ROLE_FEED) |
|
59 | 89 | auth_token = auth_token.api_key |
|
60 | 90 | self.app.get( |
|
61 | url(controller='feed', action='rss', | |
|
62 |
repo_name=backend.repo_name, |
|
|
91 | route_path( | |
|
92 | '{}_feed_home'.format(feed_type), repo_name=backend.repo_name, | |
|
93 | params=dict(auth_token=auth_token)), | |
|
63 | 94 | status=200) |
|
64 | ||
|
65 | def test_atom(self, backend): | |
|
66 | self.log_user() | |
|
67 | response = self.app.get(url(controller='feed', action='atom', | |
|
68 | repo_name=backend.repo_name)) | |
|
69 | ||
|
70 | assert response.content_type == """application/atom+xml""" | |
|
71 | assert """<?xml version="1.0" encoding="utf-8"?>""" in response | |
|
72 | ||
|
73 | tag1 = '<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-us">' | |
|
74 | tag2 = '<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom">' | |
|
75 | assert tag1 in response or tag2 in response |
@@ -510,17 +510,6 b' def make_map(config):' | |||
|
510 | 510 | controller='journal', action='toggle_following', jsroute=True, |
|
511 | 511 | conditions={'method': ['POST']}) |
|
512 | 512 | |
|
513 | # FEEDS | |
|
514 | rmap.connect('rss_feed_home', '/{repo_name}/feed/rss', | |
|
515 | controller='feed', action='rss', | |
|
516 | conditions={'function': check_repo}, | |
|
517 | requirements=URL_NAME_REQUIREMENTS) | |
|
518 | ||
|
519 | rmap.connect('atom_feed_home', '/{repo_name}/feed/atom', | |
|
520 | controller='feed', action='atom', | |
|
521 | conditions={'function': check_repo}, | |
|
522 | requirements=URL_NAME_REQUIREMENTS) | |
|
523 | ||
|
524 | 513 | #========================================================================== |
|
525 | 514 | # REPOSITORY ROUTES |
|
526 | 515 | #========================================================================== |
@@ -113,6 +113,9 b' function registerRCRoutes() {' | |||
|
113 | 113 | pyroutes.register('pullrequest_show', '/%(repo_name)s/pull-request/%(pull_request_id)s', ['repo_name', 'pull_request_id']); |
|
114 | 114 | pyroutes.register('pullrequest_show_all', '/%(repo_name)s/pull-request', ['repo_name']); |
|
115 | 115 | pyroutes.register('pullrequest_show_all_data', '/%(repo_name)s/pull-request-data', ['repo_name']); |
|
116 | pyroutes.register('changeset_home', '/%(repo_name)s/changeset/%(revision)s', ['repo_name', 'revision']); | |
|
117 | pyroutes.register('changeset_children', '/%(repo_name)s/changeset_children/%(revision)s', ['repo_name', 'revision']); | |
|
118 | pyroutes.register('changeset_parents', '/%(repo_name)s/changeset_parents/%(revision)s', ['repo_name', 'revision']); | |
|
116 | 119 | pyroutes.register('edit_repo', '/%(repo_name)s/settings', ['repo_name']); |
|
117 | 120 | pyroutes.register('edit_repo_advanced', '/%(repo_name)s/settings/advanced', ['repo_name']); |
|
118 | 121 | pyroutes.register('edit_repo_advanced_delete', '/%(repo_name)s/settings/advanced/delete', ['repo_name']); |
@@ -128,6 +131,8 b' function registerRCRoutes() {' | |||
|
128 | 131 | pyroutes.register('strip', '/%(repo_name)s/settings/strip', ['repo_name']); |
|
129 | 132 | pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']); |
|
130 | 133 | pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']); |
|
134 | pyroutes.register('rss_feed_home', '/%(repo_name)s/feed/rss', ['repo_name']); | |
|
135 | pyroutes.register('atom_feed_home', '/%(repo_name)s/feed/atom', ['repo_name']); | |
|
131 | 136 | pyroutes.register('repo_summary', '/%(repo_name)s', ['repo_name']); |
|
132 | 137 | pyroutes.register('repo_summary_slash', '/%(repo_name)s/', ['repo_name']); |
|
133 | 138 | pyroutes.register('repo_group_home', '/%(repo_group_name)s', ['repo_group_name']); |
@@ -92,17 +92,17 b'' | |||
|
92 | 92 | |
|
93 | 93 | <%def name="rss(name)"> |
|
94 | 94 | %if c.rhodecode_user.username != h.DEFAULT_USER: |
|
95 | <a title="${h.tooltip(_('Subscribe to %s rss feed')% name)}" href="${h.url('rss_feed_home',repo_name=name,auth_token=c.rhodecode_user.feed_token)}"><i class="icon-rss-sign"></i></a> | |
|
95 | <a title="${h.tooltip(_('Subscribe to %s rss feed')% name)}" href="${h.route_path('rss_feed_home', repo_name=name, _query=dict(auth_token=c.rhodecode_user.feed_token))}"><i class="icon-rss-sign"></i></a> | |
|
96 | 96 | %else: |
|
97 |
<a title="${h.tooltip(_('Subscribe to %s rss feed')% name)}" href="${h. |
|
|
97 | <a title="${h.tooltip(_('Subscribe to %s rss feed')% name)}" href="${h.route_path('rss_feed_home', repo_name=name)}"><i class="icon-rss-sign"></i></a> | |
|
98 | 98 | %endif |
|
99 | 99 | </%def> |
|
100 | 100 | |
|
101 | 101 | <%def name="atom(name)"> |
|
102 | 102 | %if c.rhodecode_user.username != h.DEFAULT_USER: |
|
103 | <a title="${h.tooltip(_('Subscribe to %s atom feed')% name)}" href="${h.url('atom_feed_home',repo_name=name,auth_token=c.rhodecode_user.feed_token)}"><i class="icon-rss-sign"></i></a> | |
|
103 | <a title="${h.tooltip(_('Subscribe to %s atom feed')% name)}" href="${h.route_path('atom_feed_home', repo_name=name, _query=dict(auth_token=c.rhodecode_user.feed_token))}"><i class="icon-rss-sign"></i></a> | |
|
104 | 104 | %else: |
|
105 |
<a title="${h.tooltip(_('Subscribe to %s atom feed')% name)}" href="${h. |
|
|
105 | <a title="${h.tooltip(_('Subscribe to %s atom feed')% name)}" href="${h.route_path('atom_feed_home', repo_name=name)}"><i class="icon-rss-sign"></i></a> | |
|
106 | 106 | %endif |
|
107 | 107 | </%def> |
|
108 | 108 |
@@ -10,8 +10,8 b'' | |||
|
10 | 10 | |
|
11 | 11 | |
|
12 | 12 | <%def name="head_extra()"> |
|
13 |
<link href="${h. |
|
|
14 |
<link href="${h. |
|
|
13 | <link href="${h.route_path('atom_feed_home', repo_name=c.rhodecode_db_repo.repo_name, _query=dict(auth_token=c.rhodecode_user.feed_token))}" rel="alternate" title="${h.tooltip(_('%s ATOM feed') % c.repo_name)}" type="application/atom+xml" /> | |
|
14 | <link href="${h.route_path('rss_feed_home', repo_name=c.rhodecode_db_repo.repo_name, _query=dict(auth_token=c.rhodecode_user.feed_token))}" rel="alternate" title="${h.tooltip(_('%s RSS feed') % c.repo_name)}" type="application/rss+xml" /> | |
|
15 | 15 | </%def> |
|
16 | 16 | |
|
17 | 17 |
@@ -14,9 +14,9 b'' | |||
|
14 | 14 | <ul class="links icon-only-links block-right"> |
|
15 | 15 | <li> |
|
16 | 16 | %if c.rhodecode_user.username != h.DEFAULT_USER: |
|
17 |
<a href="${h. |
|
|
17 | <a href="${h.route_path('atom_feed_home', repo_name=c.rhodecode_db_repo.repo_name, _query=dict(auth_token=c.rhodecode_user.feed_token))}" title="${_('RSS Feed')}"><i class="icon-rss-sign"></i></a> | |
|
18 | 18 | %else: |
|
19 |
<a href="${h. |
|
|
19 | <a href="${h.route_path('atom_feed_home', repo_name=c.rhodecode_db_repo.repo_name)}" title="${_('RSS Feed')}"><i class="icon-rss-sign"></i></a> | |
|
20 | 20 | %endif |
|
21 | 21 | </li> |
|
22 | 22 | </ul> |
|
1 | NO CONTENT: file was removed |
General Comments 0
You need to be logged in to leave comments.
Login now